mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 17:02:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822661ec81 | ||
|
|
c649331ef5 | ||
|
|
f086f7a0e8 | ||
|
|
c46cae810f | ||
|
|
397c9a1cbe | ||
|
|
71f399369c | ||
|
|
87c4c2df08 | ||
|
|
ec50c09b93 | ||
|
|
8970d6ff28 | ||
|
|
76f535aa1a | ||
|
|
9f6287f947 | ||
|
|
816231c49f | ||
|
|
975843ac1a | ||
|
|
b6760ee595 | ||
|
|
7d7a1c412f | ||
|
|
55d320db3d | ||
|
|
e89b1c85a8 | ||
|
|
a68dbfa433 | ||
|
|
e5c1f48448 | ||
|
|
22b4a341c2 | ||
|
|
37f2507d37 | ||
|
|
c05436944d | ||
|
|
3fafa42c93 | ||
|
|
8c175f47ab | ||
|
|
5babb01a56 | ||
|
|
85afda3277 | ||
|
|
fe5bf6c684 | ||
|
|
351b418b83 | ||
|
|
291d537214 | ||
|
|
570c4d0dc5 | ||
|
|
f1f221bb6d | ||
|
|
76cbaa404f | ||
|
|
c1aca449c5 | ||
|
|
a28316ed38 | ||
|
|
06a25f7393 | ||
|
|
bc06b56a5b | ||
|
|
11faa8a789 | ||
|
|
b8b8d9c180 | ||
|
|
746d9a1352 | ||
|
|
fdda687141 | ||
|
|
b68d951fd0 | ||
|
|
10c8f2e4aa | ||
|
|
ed98ed6568 |
@@ -106,10 +106,14 @@ jobs:
|
||||
# CRITICAL: Python 3.10 coverage reads pyproject.toml only when the
|
||||
# TOML extra is present; do not downgrade this back to plain coverage.
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-quality.txt
|
||||
python -m pip install numpy pillow aiohttp "coverage[toml]"
|
||||
- name: R120 preflight
|
||||
run: |
|
||||
python scripts/preflight_check.py --strict
|
||||
- name: Static-analysis policy
|
||||
run: |
|
||||
python scripts/verify_static_analysis_policy.py
|
||||
- name: Run MAE hard-guarantee suites
|
||||
env:
|
||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_mae
|
||||
@@ -206,8 +210,9 @@ jobs:
|
||||
python scripts/check_supply_chain_hardening.py
|
||||
- name: Frontend Audit (npm)
|
||||
run: |
|
||||
# Audit only production dependencies, ignore dev
|
||||
npm audit --production
|
||||
# Development tooling is part of the build/test trust boundary.
|
||||
npm ci
|
||||
npm audit --audit-level=high
|
||||
- name: Install backend deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
@@ -31,6 +31,10 @@ jobs:
|
||||
# Install black/isort explicitly so CI doesn't fail due to missing tools
|
||||
# if a hook is configured to run via system python.
|
||||
pip install pre-commit black==24.1.1 isort==5.13.2
|
||||
pip install -r requirements-quality.txt
|
||||
|
||||
- name: Verify static-analysis policy directly
|
||||
run: python scripts/verify_static_analysis_policy.py
|
||||
|
||||
- name: Run all pre-commit hooks
|
||||
run: pre-commit run --all-files --show-diff-on-failure
|
||||
|
||||
@@ -41,6 +41,22 @@ repos:
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: static-analysis-policy
|
||||
name: incremental Ruff/Mypy static-analysis policy
|
||||
# Keep isolated pins aligned with requirements-quality.txt and policy JSON.
|
||||
entry: python -B scripts/verify_static_analysis_policy.py
|
||||
language: python
|
||||
additional_dependencies:
|
||||
- ruff==0.15.20
|
||||
- mypy==2.2.0
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: production-dependency-boundary
|
||||
name: production dependency boundary contract
|
||||
entry: python -B scripts/verify_production_dependencies.py
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
# Secret detection
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
|
||||
@@ -91,6 +91,61 @@ Deployment profiles and hardening references:
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Startup, security posture, and architecture boundaries hardened</strong></summary>
|
||||
|
||||
- Added an executable production dependency boundary check that detects forbidden ownership
|
||||
direction, cycles, and dynamic-import drift without importing application modules.
|
||||
- Patched high-severity transitive frontend development dependencies and made local full-test,
|
||||
pre-push, and CI security paths rebuild the exact lockfile tree before blocking high/critical
|
||||
findings across production and development dependencies.
|
||||
- Startup health now exposes typed, redacted phase, readiness, retry, fatal, timing, and optional
|
||||
warmup outcomes.
|
||||
- Process-static deployment and security decisions now resolve once into an immutable,
|
||||
secret-free posture snapshot reused across startup and authorization boundaries.
|
||||
- Bootstrap lifecycle, route registration, and effective posture implementations now live in
|
||||
focused owner packages while legacy import identities remain compatible.
|
||||
- The public systemd environment template now follows `.env.example` conventions, while
|
||||
secret-bearing deployment environment files remain excluded from version control.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Host alignment, Parameter Lab, and native workflow ownership refreshed</strong></summary>
|
||||
|
||||
- Legacy fixed-bundle Desktop and current managed-install Comfy-Desktop are modeled separately;
|
||||
current bridge presence is detected without granting privileged capability access.
|
||||
- ComfyUI's `datasets` user-data root is excluded from model inventory and Model Manager
|
||||
destinations.
|
||||
- Parameter Lab now accepts only bounded scalar values and correlates queued runs through exact
|
||||
request-ID receipts, failing explicitly on unsupported or ambiguous host queue shapes.
|
||||
- Advanced 3D `result` references are recognized as bounded output links without inspecting
|
||||
later metadata or rendering binary content.
|
||||
- Native ComfyUI video/webcam inputs, audio and text-to-speech flows, and the Graph/Workflows
|
||||
workspace remain host-owned instead of being duplicated by OpenClaw.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Maintainability, scale safeguards, and verification governance strengthened</strong></summary>
|
||||
|
||||
- Added pinned incremental Ruff and Mypy enforcement that blocks new production-code debt in
|
||||
local and CI validation without requiring an unsafe repository-wide rewrite.
|
||||
- Added deterministic scale baselines for large jobs history, connector summaries, and frontend
|
||||
output normalization, with exact payload and call-count budgets plus advisory timing evidence.
|
||||
- Hardened selected exception boundaries so cancellation, compatibility fallback, status mapping,
|
||||
and redacted diagnostics remain explicit instead of being swallowed by broad catches.
|
||||
- Split the largest API route/config, connector command, Slack/Feishu adapter, and frontend
|
||||
Settings/API hotspots into focused owner modules while preserving public routes, patch seams,
|
||||
security checks, DOM behavior, and host compatibility.
|
||||
- Promoted the governed backend coverage floor to 55% using consecutive release-cycle evidence,
|
||||
all-hotspot regression ownership, atomic config checks, and fail-closed evidence validation.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Secure jobs visibility, host compatibility, output previews, and graph guards refreshed</strong></summary>
|
||||
|
||||
- `GET /openclaw/jobs` now provides an Admin-only, versioned jobs view with bounded
|
||||
@@ -122,53 +177,6 @@ Deployment profiles and hardening references:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Package hygiene, runtime cache ownership, and tool diagnostics tightened</strong></summary>
|
||||
|
||||
- Moved developer-only verification helpers out of the package root and into the dedicated developer tooling area, keeping the shipped custom-node pack boundary clearer.
|
||||
- The default external-tool allowlist now resolves from the package-owned `data/tools_allowlist.json`; custom allowlists should use `OPENCLAW_TOOLS_CONFIG_PATH` instead of relying on state-directory shadow files.
|
||||
- Runtime cache and external-tool sandbox scratch paths are treated as state-directory-owned generated data, while repo-local `.tmp`, virtualenv, and frontend dependency folders remain regenerated local tooling artifacts.
|
||||
- External-tool failures now have deterministic service-level diagnostics for missing sandbox runtime, missing executable/interpreter, timeout, workspace/path violation, and process failure, without adding Docker or broad fallback execution.
|
||||
- Python formatter/import-order settings and targeted package-hygiene regressions were aligned so validation catches future root clutter, package-resource, and runtime-cache ownership drift.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>ComfyUI host compatibility, media outputs, model folders, and prompt attribution refreshed</strong></summary>
|
||||
|
||||
- Refreshed the published compatibility baseline for ComfyUI `1377a2f7` (`v0.27.0-47-g1377a2f7`, pyproject `0.27.0`), standalone frontend `1.48.1` (`ceb5ae1eba`, `v1.48.1-1-gceb5ae1eba`), and Desktop `0.9.4` with core `0.22.3` plus embedded frontend `1.43.18`.
|
||||
- Reconciled active prompt state after backend or SSE reconnects so completed prompts are not left in the active queue lane after a host recovery.
|
||||
- Updated sidebar registration to prefer the current ComfyUI sidebar store API and keep the deprecated frontend facade as a compatibility fallback for older hosts.
|
||||
- Aligned Model Manager and preflight diagnostics with current ComfyUI model folder names, including newer managed keys such as `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, while retaining legacy aliases such as `clip` and `unet`.
|
||||
- Made output parsing media-aware for current previewable result groups (`images`, `video`, `audio`, `3d`, and bounded `text`) while keeping image callbacks compatible, supporting optional hash-backed refs when host metadata is present, and keeping asset-only identifiers as explicit fallback states instead of silently upgrading to `/api/assets`.
|
||||
- Added Job Monitor support for allowlisted text files under the host `files` output key. Previews use same-origin `/view`, a 5-second timeout, strict textual MIME and UTF-8 checks, a 64-KiB streaming cap, a 4,096-character display cap, and literal text rendering; rejected or unsupported responses keep an explicit source link.
|
||||
- OpenClaw prompt submissions now include stable `comfy_usage_source` attribution when missing, without overwriting caller-provided attribution or copying prompt/tenant/trace content into that field.
|
||||
- Updated public release/support/troubleshooting docs to match the refreshed host facts and avoid exposing maintainer-only planning paths or machine-local links.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Supply-chain and CI hardening refreshed for dependency and release workflows</strong></summary>
|
||||
|
||||
- CI and local validation now include a read-only supply-chain hardening check for known malicious package-family and persistence indicators.
|
||||
- Frontend dependency bootstrap paths use lockfile-driven `npm ci` in validation workflows.
|
||||
- Release and dependency-review workflows now have tighter permissions, pinned publish-action usage, and PR-time dependency review for dependency manifest changes.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Connector replay, reply visibility, and scheduled delivery behavior aligned with current chat workflows</strong></summary>
|
||||
|
||||
- Connector event handling now distinguishes duplicate committed actions from retryable pre-delivery failures across supported chat adapters, reducing accidental re-execution while still allowing safe retries.
|
||||
- Reply visibility is now governed by a shared connector policy for direct messages, shared chats, threads, internal delivery, and tool-only contexts; suppressed text is logged as a successful no-op instead of a delivery failure.
|
||||
- Telegram topics, Slack threads/workspaces, and Feishu account/workspace context are preserved for immediate replies and delayed result or approval follow-up, while approval/action buttons remain visible.
|
||||
|
||||
</details>
|
||||
|
||||
See full update history: [docs/release/recent_updates.md](docs/release/recent_updates.md)
|
||||
|
||||
</details>
|
||||
@@ -186,6 +194,9 @@ See full update history: [docs/release/recent_updates.md](docs/release/recent_up
|
||||
- [Basic operations](#basic-operations)
|
||||
- [Reverse proxy and exposure notes](#reverse-proxy-and-exposure-notes)
|
||||
- [Nodes](#nodes)
|
||||
- [Native Media Inputs](#native-media-inputs)
|
||||
- [Native Audio and Text-to-Speech](#native-audio-and-text-to-speech)
|
||||
- [Workflow Workspace Ownership](#workflow-workspace-ownership)
|
||||
- [Node Portability and Workflow Fallback](#node-portability-and-workflow-fallback)
|
||||
- [Extension UI](#extension-ui)
|
||||
- [Sidebar Modules](#sidebar-modules)
|
||||
@@ -400,6 +411,51 @@ The current node category is `openclaw`; serialized workflows that still referen
|
||||
|
||||
See `web/docs/` for node usage notes.
|
||||
|
||||
### Native Media Inputs
|
||||
|
||||
Current supported ComfyUI hosts already provide the media-ingestion nodes needed by
|
||||
OpenClaw image consumers:
|
||||
|
||||
- For video frames, connect ComfyUI's `Load Video` node to `Get Video Components` and use
|
||||
its `images` output. File selection and upload stay inside the ComfyUI input boundary.
|
||||
- For a camera snapshot, use ComfyUI's `Webcam Capture` node. Camera permission and
|
||||
secure-context requirements are handled by the host frontend; the captured frame is
|
||||
uploaded to ComfyUI temporary storage and returned as an `IMAGE`.
|
||||
|
||||
OpenClaw intentionally does not register duplicate video-decoder or camera-capture nodes.
|
||||
This keeps media decoding, browser device permission, and native `VIDEO` / `IMAGE`
|
||||
compatibility owned by ComfyUI and avoids adding a second ffmpeg or backend-device access
|
||||
path.
|
||||
|
||||
### Native Audio and Text-to-Speech
|
||||
|
||||
Use native ComfyUI audio workflows for text-to-speech generation. Connect a host-provided
|
||||
voice selector and TTS node to the standard `AUDIO` flow, then use ComfyUI's native audio
|
||||
preview or save nodes. Provider availability, authentication, voice/model choice, credits,
|
||||
and output format remain owned by the host workflow and its installed nodes.
|
||||
|
||||
OpenClaw Jobs can observe audio results exposed by ComfyUI history, but the remote chat
|
||||
connector remains a text command-and-control surface. It does not capture microphone
|
||||
input, synthesize speech independently, persist connector audio or transcripts, or return
|
||||
workflow audio as chat voice attachments. Start an approved audio workflow through the
|
||||
existing remote controls when needed, then inspect or play its result in ComfyUI or
|
||||
OpenClaw Jobs.
|
||||
|
||||
### Workflow Workspace Ownership
|
||||
|
||||
The ComfyUI Graph Canvas is the authoritative workspace for loading, arranging,
|
||||
inspecting, saving, and switching workflows. Use the host Workflows sidebar and tabs,
|
||||
drafts, and subgraphs so graph migrations, custom-node registration, active and modified
|
||||
state, and Desktop compatibility remain owned by ComfyUI.
|
||||
|
||||
OpenClaw complements that workspace: Explorer, preflight, checkpoints, and portability or
|
||||
rewrite tools diagnose and guard workflows; Parameter Lab reads and replays bounded values
|
||||
through the active host graph; Jobs observes execution and results.
|
||||
|
||||
OpenClaw intentionally does not create a second canvas, workflow store, serializer, or
|
||||
remote graph editor. Keeping one graph owner avoids divergent drafts, subgraph identifiers,
|
||||
widget state, and execution context.
|
||||
|
||||
### Node Portability and Workflow Fallback
|
||||
|
||||
Current builds expose a stable portability contract for the shipped OpenClaw nodes so workflow diagnostics can distinguish "custom node missing" from a generic import/runtime failure:
|
||||
@@ -425,13 +481,22 @@ Current sidebar composition keeps `web/openclaw_ui.js` as the shell root and rou
|
||||
- queue polling and transient banners: `web/openclaw_queue_monitor.js` and `web/openclaw_banner_manager.js`
|
||||
- persistent operator notifications: `web/openclaw_notification_center.js`
|
||||
- tab registration/remount behavior: `web/openclaw_tabs.js`
|
||||
- API transport/session core: `web/openclaw_api.js`, with config, generation, resource, model,
|
||||
and event route families owned by `web/openclaw_api_*.js` modules behind the same singleton
|
||||
- Settings composition and async generation lifecycle: `web/tabs/settings_tab.js`, with status,
|
||||
LLM, secrets, logs, DOM, and lifecycle ownership in focused `settings_tab_*.js` modules
|
||||
- shared error + compatibility helpers: `web/openclaw_utils.js`
|
||||
|
||||
New shell/tab wiring should use the shared text-safe DOM helpers in `web/openclaw_utils.js` instead of duplicating ad hoc element construction in individual tabs.
|
||||
|
||||
Canonical DOM/class ownership is now centered on `openclaw-*`; legacy `moltbot-*` class compatibility is still supported through shared runtime aliasing instead of duplicated markup in each tab template.
|
||||
|
||||
The sidebar now also resolves and stamps its active host surface (`standalone_frontend` vs desktop-embedded host) and reference metadata at mount time, so Desktop `0.9.4` embedded-frontend lag against standalone frontend `1.48.1` is explicit and testable instead of inferred from runtime accidents.
|
||||
The sidebar now also resolves and stamps its active host surface (`standalone_frontend`, legacy
|
||||
`desktop`, or current managed-install `comfy_desktop`) and reference metadata at mount time, so
|
||||
Desktop `0.9.4` embedded-frontend lag against standalone frontend `1.49.1` is explicit and
|
||||
testable. The current Comfy-Desktop `1.0.32-rc.1` reference keeps hosted component versions
|
||||
installation-specific and recognizes `window.__comfyDesktop2` as presence metadata only; bridge
|
||||
detection does not authorize privileged capability calls.
|
||||
|
||||
Sidebar registration prefers ComfyUI's current sidebar store API and falls back to the deprecated frontend facade when running on older host bundles. Hosts without either sidebar API use the legacy menu fallback instead of failing extension setup.
|
||||
|
||||
@@ -453,7 +518,7 @@ The OpenClaw sidebar includes these built-in tabs. Some tabs are capability-gate
|
||||
| `Explorer` | Inventory/preflight diagnostics and snapshot/checkpoint troubleshooting workflows, including snapshot-first inventory refresh state (`snapshot_ts`, `scan_state`, `stale`, `last_error`) and suppressed inactive-branch findings. | [Operator UX Features](#operator-ux-features), [Troubleshooting](#troubleshooting) |
|
||||
| `Packs` | Dedicated pack lifecycle tab for import/export/delete under admin boundary. | [API Overview](#api-overview) |
|
||||
| `PNG Info` | Inspects saved generation images through drag-and-drop, file picker, or scoped paste, parses A1111 infotext plus ComfyUI `prompt` / `workflow` metadata, shows extracted prompt and generation fields when recoverable, and keeps raw metadata visible for operator inspection. | [API Overview](#api-overview), [Troubleshooting](#troubleshooting) |
|
||||
| `Model Manager` | Searches model catalog/install records, queues managed downloads, monitors task lifecycle, and imports completed tasks into the managed install root with current ComfyUI folder-key normalization, including `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, plus legacy type aliases. | [API Overview](#api-overview), [Troubleshooting](#troubleshooting) |
|
||||
| `Model Manager` | Searches model catalog/install records, queues managed downloads, monitors task lifecycle, and imports completed tasks into the managed install root with current ComfyUI folder-key normalization, including `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, plus legacy type aliases. User-managed `datasets` remain outside model inventory and install destinations. | [API Overview](#api-overview), [Troubleshooting](#troubleshooting) |
|
||||
| `Parameter Lab` | Runs bounded sweep/compare experiments, stores history, and replays parameters back into the graph while preserving non-numeric host node IDs. | [Operator UX Features](#operator-ux-features) |
|
||||
|
||||
## Operator UX Features
|
||||
@@ -490,6 +555,11 @@ Parameter Lab now supports experiment history and run replay:
|
||||
- `History` lists saved experiments from local state.
|
||||
- `Load` opens stored experiment details and run statuses.
|
||||
- `Replay` applies a selected run's parameter values back into the active workflow graph without coercing string or non-numeric host node IDs.
|
||||
- Sweep and compare inputs accept bounded strings, booleans, and finite numbers; structured values
|
||||
and unsupported sweep strategies fail validation instead of being guessed or silently coerced.
|
||||
- Queued runs use request-ID-correlated receipts to bind the exact host prompt ID. Unsupported,
|
||||
malformed, busy, or ambiguous host queue boundaries fail explicitly rather than borrowing a
|
||||
globally recent prompt.
|
||||
|
||||
This makes iterative tuning and backtracking faster without manually retyping prior parameter sets.
|
||||
|
||||
@@ -747,8 +817,11 @@ The SOP already defines:
|
||||
|
||||
- the docs-only exception for strictly documentation/planning/SOP changes
|
||||
- one-command full test scripts for Windows and Linux/WSL
|
||||
- supply-chain hardening checks and lockfile-driven frontend dependency installation
|
||||
- the CI-parity backend coverage and governance workflow
|
||||
- supply-chain hardening checks, lockfile-driven frontend dependency installation, and blocking
|
||||
high/critical audits across production and development dependencies
|
||||
- pinned incremental Ruff/Mypy debt enforcement and deterministic scale regression baselines
|
||||
- the CI-parity backend coverage workflow with the governed 55% floor, retained release-cycle
|
||||
evidence, and required hotspot ownership
|
||||
|
||||
## Updating
|
||||
|
||||
@@ -787,7 +860,10 @@ The connector currently remains an **optional attached subsystem inside this rep
|
||||
|
||||
Read [SECURITY.md](docs/SECURITY.md) before exposing any endpoint beyond localhost. The project is designed to be secure-by-default (deny-by-default auth, SSRF protections, redaction, bounded outputs), but unsafe deployment can still create risk.
|
||||
|
||||
Repository maintenance workflows also include supply-chain controls: CI/local validation scans declared dependencies and selected workspace persistence surfaces for known malicious indicators, frontend installs are lockfile-driven, and dependency manifest changes receive PR-time review.
|
||||
Repository maintenance workflows also include supply-chain controls: CI/local validation scans
|
||||
declared dependencies and selected workspace persistence surfaces for known malicious indicators,
|
||||
frontend installs are lockfile-driven, high/critical findings across production and development
|
||||
dependencies block acceptance, and dependency manifest changes receive PR-time review.
|
||||
|
||||
### Security Deployment Guide
|
||||
|
||||
|
||||
+11
-1
@@ -53,7 +53,17 @@ def _bootstrap_openclaw_routes() -> None:
|
||||
from .services.route_bootstrap import register_routes_once
|
||||
else:
|
||||
from services.route_bootstrap import register_routes_once
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
try:
|
||||
if __package__:
|
||||
from .services.startup_lifecycle import mark_bootstrap_import_failed
|
||||
else:
|
||||
from services.startup_lifecycle import mark_bootstrap_import_failed
|
||||
|
||||
mark_bootstrap_import_failed(exc)
|
||||
except Exception:
|
||||
# IMPORTANT: diagnostics must not mask the original compatibility fallback.
|
||||
pass
|
||||
return
|
||||
|
||||
register_routes_once()
|
||||
|
||||
+74
-807
@@ -166,6 +166,29 @@ except Exception:
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.api.config")
|
||||
|
||||
(
|
||||
ConfigHandlerDependencies,
|
||||
config_get_response,
|
||||
config_put_response,
|
||||
) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.config_projection_handlers",
|
||||
"api.config_projection_handlers",
|
||||
("ConfigHandlerDependencies", "config_get_response", "config_put_response"),
|
||||
)
|
||||
(llm_models_response,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.config_model_handlers",
|
||||
"api.config_model_handlers",
|
||||
("llm_models_response",),
|
||||
)
|
||||
(llm_chat_response, llm_test_response) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.config_llm_handlers",
|
||||
"api.config_llm_handlers",
|
||||
("llm_chat_response", "llm_test_response"),
|
||||
)
|
||||
|
||||
(
|
||||
_MODEL_LIST_CACHE,
|
||||
_MODEL_LIST_MAX_ENTRIES,
|
||||
@@ -258,6 +281,46 @@ except ImportError:
|
||||
]
|
||||
|
||||
|
||||
def _handler_dependencies():
|
||||
"""Capture established facade patch seams for owned config handlers."""
|
||||
|
||||
return ConfigHandlerDependencies(
|
||||
web=web,
|
||||
logger=logger,
|
||||
provider_catalog=PROVIDER_CATALOG,
|
||||
pack_version=PACK_VERSION,
|
||||
require_observability_access=require_observability_access,
|
||||
require_admin_token=require_admin_token,
|
||||
require_same_origin_if_no_token=require_same_origin_if_no_token,
|
||||
resolve_token_info=resolve_token_info,
|
||||
emit_audit_event=emit_audit_event,
|
||||
check_rate_limit=check_rate_limit,
|
||||
build_rate_limit_response=build_rate_limit_response,
|
||||
get_client_ip=get_client_ip,
|
||||
is_loopback=is_loopback,
|
||||
get_admin_token=get_admin_token,
|
||||
get_apply_semantics=get_apply_semantics,
|
||||
get_effective_config=get_effective_config,
|
||||
get_llm_egress_controls=get_llm_egress_controls,
|
||||
get_runtime_guardrails=get_runtime_guardrails,
|
||||
get_settings_schema=get_settings_schema,
|
||||
is_loopback_client=is_loopback_client,
|
||||
update_config=update_config,
|
||||
tenant_boundary_error=TenantBoundaryError,
|
||||
request_tenant_scope=request_tenant_scope,
|
||||
runtime_only_code=CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
|
||||
payload_contains_runtime_guardrails=payload_contains_runtime_guardrails,
|
||||
model_cache_get=_cache_get,
|
||||
format_llm_ssrf_error=_format_llm_ssrf_error,
|
||||
llm_insecure_override_enabled=_llm_insecure_override_enabled,
|
||||
fetch_remote_model_list=fetch_remote_model_list,
|
||||
get_stale_cached_models=get_stale_cached_models,
|
||||
resolve_model_list_target=resolve_model_list_target,
|
||||
validate_model_list_target=validate_model_list_target,
|
||||
llm_client=LLMClient,
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.OBSERVABILITY,
|
||||
risk=RiskTier.LOW,
|
||||
@@ -272,73 +335,8 @@ async def config_get_handler(request: web.Request) -> web.Response:
|
||||
Returns effective config, sources, and provider catalog.
|
||||
Enforced by S14 Access Control.
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
# S14: Access Control
|
||||
allowed, error = require_observability_access(request)
|
||||
if not allowed:
|
||||
return web.json_response({"ok": False, "error": error}, status=403)
|
||||
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
effective, sources = get_effective_config(tenant_id=tenant.tenant_id)
|
||||
guardrails = get_runtime_guardrails()
|
||||
if guardrails.get("status") != "ok":
|
||||
emit_audit_event(
|
||||
action="runtime.guardrails",
|
||||
target="runtime_guardrails",
|
||||
outcome="warn",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"code": guardrails.get("code"),
|
||||
"violations": guardrails.get("violations", []),
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"config": effective,
|
||||
"sources": sources,
|
||||
"runtime_guardrails": guardrails,
|
||||
"providers": PROVIDER_CATALOG,
|
||||
# R70: Settings schema for frontend type coercion / validation
|
||||
"schema": get_settings_schema(),
|
||||
# Simplified UX: writes are controlled by admin access policy, not a separate env "enable" flag.
|
||||
"write_enabled": True,
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": e.code, "message": str(e)},
|
||||
status=403,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error getting config")
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
# CRITICAL: owned implementation performs require_observability_access before reads.
|
||||
return await config_get_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -359,212 +357,9 @@ async def llm_models_handler(request: web.Request) -> web.Response:
|
||||
- loopback-only unless OPENCLAW_ALLOW_REMOTE_ADMIN=1
|
||||
- SSRF policy enforced via LLM egress controls, including scoped private-network allowance
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
# Admin boundary
|
||||
allowed, err = require_admin_token(request)
|
||||
if not allowed:
|
||||
emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"reason": err or "unauthorized",
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": err or "Unauthorized",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Optional loopback check (match config_put behavior)
|
||||
import os
|
||||
|
||||
allow_remote = (
|
||||
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
|
||||
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
|
||||
or ""
|
||||
).lower()
|
||||
if allow_remote not in ("1", "true", "yes", "on"):
|
||||
remote = request.remote or ""
|
||||
if not is_loopback_client(remote):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
provider_override = (request.query.get("provider") or "").strip().lower()
|
||||
effective, _sources = get_effective_config(tenant_id=tenant.tenant_id)
|
||||
|
||||
try:
|
||||
target = resolve_model_list_target(
|
||||
provider_override,
|
||||
effective,
|
||||
tenant.tenant_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": str(e)},
|
||||
status=400,
|
||||
)
|
||||
except TypeError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": str(e)},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# R60: Check bounded TTL+LRU cache
|
||||
cached_entry = _cache_get(target.cache_key)
|
||||
if cached_entry:
|
||||
_ts, models = cached_entry
|
||||
if isinstance(models, list):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
|
||||
# CRITICAL:
|
||||
# Local providers (e.g. ollama/lmstudio) intentionally work without API keys.
|
||||
# Do not change this gate back to `if not api_key`, or local model-list loading
|
||||
# will regress with false 400 errors.
|
||||
if target.requires_api_key and not target.api_key:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"No API key configured for provider '{target.provider}'.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# SSRF policy
|
||||
try:
|
||||
controls = get_llm_egress_controls(
|
||||
target.provider,
|
||||
target.base_url,
|
||||
allow_private_network=target.allow_private_network,
|
||||
)
|
||||
validate_model_list_target(
|
||||
target,
|
||||
controls,
|
||||
allow_insecure_base_url=_llm_insecure_override_enabled(),
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": _format_llm_ssrf_error(e)},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Fetch /models
|
||||
try:
|
||||
try:
|
||||
from ..services.safe_io import SSRFError
|
||||
except ImportError:
|
||||
from services.safe_io import SSRFError # type: ignore
|
||||
|
||||
models = fetch_remote_model_list(
|
||||
target,
|
||||
controls,
|
||||
pack_version=PACK_VERSION,
|
||||
allow_insecure_base_url=_llm_insecure_override_enabled(),
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": False,
|
||||
}
|
||||
)
|
||||
except SSRFError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": _format_llm_ssrf_error(e)},
|
||||
status=403,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
# safe_request_json raises RuntimeError for HTTP errors (non-200) contextually
|
||||
# check if it looks like an HTTP error
|
||||
str_e = str(e)
|
||||
if "HTTP" in str_e:
|
||||
# Fallback: serve stale cache entry (if any) on fetch failure
|
||||
stale = get_stale_cached_models(target.cache_key)
|
||||
if stale:
|
||||
_ts, models = stale
|
||||
warning = f"Using cached list (refresh failed: {str_e})"
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": f"Upstream error: {str_e}"}, status=502
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
stale = get_stale_cached_models(target.cache_key)
|
||||
if stale:
|
||||
# IMPORTANT:
|
||||
# Test path intentionally injects network failures to verify cache fallback.
|
||||
# Keep this as warning (no traceback) to avoid noisy false-alarm logs.
|
||||
logger.warning(
|
||||
"Model list refresh failed, serving cached list: %s", e
|
||||
)
|
||||
_ts, models = stale
|
||||
warning = f"Using cached list (refresh failed: {str(e)})"
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
logger.exception("Failed to fetch model list")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
except TenantBoundaryError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": e.code, "message": str(e)},
|
||||
status=403,
|
||||
)
|
||||
# CRITICAL: owned implementation performs require_admin_token( before network access.
|
||||
# CRITICAL S65: fetch_remote_model_list remains the safe_request_json egress owner.
|
||||
return await llm_models_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -580,182 +375,8 @@ async def config_put_handler(request: web.Request) -> web.Response:
|
||||
PUT /moltbot/config
|
||||
Updates non-secret LLM config. Protected by admin boundary (S13) + CSRF (S26+).
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
# S26+: CSRF protection for convenience mode
|
||||
admin_token_configured = bool(get_admin_token())
|
||||
resp = require_same_origin_if_no_token(request, admin_token_configured)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# R99/S46: resolve identity context for non-repudiation audits.
|
||||
token_info = resolve_token_info(request)
|
||||
|
||||
# Still enforce admin requirement (which checks hierarchy)
|
||||
allowed, err = require_admin_token(request)
|
||||
if not allowed:
|
||||
emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": err or "admin_token_required"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": err or "Unauthorized",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
# S13: Optional loopback check
|
||||
import os
|
||||
|
||||
allow_remote = (
|
||||
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
|
||||
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
|
||||
or ""
|
||||
).lower()
|
||||
if allow_remote not in ("1", "true", "yes", "on"):
|
||||
# Use S14 is_loopback which handles ipv6/mapped
|
||||
remote = get_client_ip(request)
|
||||
if not is_loopback(remote):
|
||||
emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": "remote_admin_denied", "remote": remote},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Invalid JSON body",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# S66: Runtime guardrails are ENV-driven + runtime-only and must never be
|
||||
# persisted via config writes (prevents config drift / silent downgrade paths).
|
||||
if payload_contains_runtime_guardrails(body):
|
||||
emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=400,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"reason": "runtime_guardrails_runtime_only",
|
||||
"code": CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "runtime_guardrails are runtime-only (ENV-driven) and cannot be persisted via /config",
|
||||
"code": CODE_RUNTIME_ONLY_PERSIST_FORBIDDEN,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Extract LLM config updates
|
||||
updates = body.get("llm", body) # Support both { llm: {...} } and {...}
|
||||
if not isinstance(updates, dict):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Expected object with config fields",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
success, errors = update_config(updates, tenant_id=tenant.tenant_id)
|
||||
|
||||
# R99: Standardized Audit Emission
|
||||
emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="allow" if success else "error",
|
||||
token_info=token_info,
|
||||
status_code=200 if success else 400,
|
||||
details=(
|
||||
{"tenant_id": tenant.tenant_id, "errors": errors}
|
||||
if errors
|
||||
else {"tenant_id": tenant.tenant_id}
|
||||
),
|
||||
request=request,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"errors": errors,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Return updated config
|
||||
effective, sources = get_effective_config(tenant_id=tenant.tenant_id)
|
||||
|
||||
# R53: Calculate apply semantics
|
||||
apply_info = get_apply_semantics(list(updates.keys()))
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"config": effective,
|
||||
"sources": sources,
|
||||
"apply": apply_info,
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as e:
|
||||
emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": e.code},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": e.code, "message": str(e)},
|
||||
status=403,
|
||||
)
|
||||
# CRITICAL: owned implementation performs require_admin_token( before mutation.
|
||||
return await config_put_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -771,215 +392,8 @@ async def llm_test_handler(request: web.Request) -> web.Response:
|
||||
POST /moltbot/llm/test
|
||||
Tests LLM connection. Protected by admin boundary (S13) + CSRF (S26+).
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
try:
|
||||
from ..services.async_utils import run_in_thread
|
||||
except ImportError:
|
||||
from services.async_utils import run_in_thread
|
||||
try:
|
||||
# IMPORTANT: use package-relative import in ComfyUI runtime.
|
||||
# CRITICAL: Missing this import causes NameError in provider error handling.
|
||||
from ..services.provider_errors import ProviderHTTPError
|
||||
except ImportError:
|
||||
from services.provider_errors import ProviderHTTPError # type: ignore
|
||||
|
||||
# S26+: CSRF protection for convenience mode
|
||||
admin_token_configured = bool(get_admin_token())
|
||||
resp = require_same_origin_if_no_token(request, admin_token_configured)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
|
||||
# S13: Validate admin boundary
|
||||
allowed, err = require_admin_token(request)
|
||||
if not allowed:
|
||||
emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target="llm",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": err or "unauthorized"},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": err or "Unauthorized",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
# IMPORTANT (Settings UX / provider mismatch):
|
||||
# - The Settings UI allows selecting provider/model/base_url without persisting config immediately.
|
||||
# - If this endpoint only uses effective config, "Test Connection" can misleadingly test the
|
||||
# previous provider (often "openai") and report: "API key not configured for provider 'openai'"
|
||||
# even when the UI is set to Gemini and a Gemini key is stored.
|
||||
# Therefore, accept optional overrides in the JSON body.
|
||||
#
|
||||
# Contract:
|
||||
# - Empty body -> test effective config
|
||||
# - Body may include: provider, model, base_url, timeout_sec, max_retries
|
||||
try:
|
||||
body = await request.json()
|
||||
if body is None:
|
||||
body = {}
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
if body and not isinstance(body, dict):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Expected JSON object body (or empty body)"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
provider = (
|
||||
body.get("provider") if isinstance(body.get("provider"), str) else None
|
||||
)
|
||||
model = body.get("model") if isinstance(body.get("model"), str) else None
|
||||
base_url = (
|
||||
body.get("base_url") if isinstance(body.get("base_url"), str) else None
|
||||
)
|
||||
|
||||
timeout_val = body.get("timeout_sec")
|
||||
timeout_sec = None
|
||||
if (
|
||||
isinstance(timeout_val, (int, float, str))
|
||||
and str(timeout_val).strip() != ""
|
||||
):
|
||||
try:
|
||||
timeout_sec = int(timeout_val)
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "timeout_sec must be an integer"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
retries_val = body.get("max_retries")
|
||||
max_retries = None
|
||||
if (
|
||||
isinstance(retries_val, (int, float, str))
|
||||
and str(retries_val).strip() != ""
|
||||
):
|
||||
try:
|
||||
max_retries = int(retries_val)
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "max_retries must be an integer"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Initialize client (uses effective config by default; overrides if provided)
|
||||
client = LLMClient(
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
timeout=timeout_sec,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
# Run test in a worker thread since LLMClient is sync
|
||||
result = await run_in_thread(
|
||||
client.complete,
|
||||
system="You are a test assistant.",
|
||||
user_message="Respond with exactly: OK",
|
||||
max_tokens=10,
|
||||
)
|
||||
|
||||
# Check result
|
||||
if result and "text" in result:
|
||||
emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target=f"{client.provider}:{client.model}",
|
||||
outcome="allow",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": client.provider,
|
||||
"model": client.model,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"message": "Connection successful",
|
||||
"response": result["text"].strip(),
|
||||
"provider": client.provider,
|
||||
"model": client.model,
|
||||
}
|
||||
)
|
||||
|
||||
emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target=f"{client.provider}:{client.model}",
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": client.provider,
|
||||
"model": client.model,
|
||||
"error": "Empty response",
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Empty or invalid response from LLM",
|
||||
}
|
||||
)
|
||||
except TenantBoundaryError as e:
|
||||
emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target="llm",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": e.code},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": e.code, "message": str(e)},
|
||||
status=403,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("LLM test failed")
|
||||
emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target="llm",
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500,
|
||||
details={"error": str(e)},
|
||||
request=request,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
# CRITICAL: owned implementation performs require_admin_token( before provider access.
|
||||
return await llm_test_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -996,152 +410,5 @@ async def llm_chat_handler(request: web.Request) -> web.Response:
|
||||
Run a simple chat completion using server-side LLM config + keys.
|
||||
This endpoint is intended for the connector; no prompt content is logged.
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
try:
|
||||
from ..services.async_utils import run_in_thread
|
||||
except ImportError:
|
||||
from services.async_utils import run_in_thread
|
||||
try:
|
||||
# IMPORTANT: use package-relative import in ComfyUI runtime.
|
||||
# CRITICAL: Missing this import causes NameError in provider error handling.
|
||||
from ..services.provider_errors import ProviderHTTPError
|
||||
except ImportError:
|
||||
from services.provider_errors import ProviderHTTPError # type: ignore
|
||||
|
||||
# S28: CSRF protection for convenience mode (no admin token configured)
|
||||
admin_token_configured = bool(get_admin_token())
|
||||
resp = require_same_origin_if_no_token(request, admin_token_configured)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "admin"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# NOTE: Keep this server-side. Connector cannot access UI-stored secrets directly.
|
||||
# This endpoint ensures keys are resolved via backend config + secret store.
|
||||
# S13: Validate admin boundary (or loopback if no admin token configured)
|
||||
token_info = resolve_token_info(request)
|
||||
allowed, err = require_admin_token(request)
|
||||
if not allowed:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": err or "Unauthorized",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Expected JSON object body"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
system = body.get("system") if isinstance(body.get("system"), str) else ""
|
||||
user_message = (
|
||||
body.get("user_message")
|
||||
if isinstance(body.get("user_message"), str)
|
||||
else body.get("message") if isinstance(body.get("message"), str) else ""
|
||||
)
|
||||
temperature = (
|
||||
body.get("temperature")
|
||||
if isinstance(body.get("temperature"), (int, float))
|
||||
else 0.7
|
||||
)
|
||||
max_tokens = (
|
||||
body.get("max_tokens") if isinstance(body.get("max_tokens"), int) else 1024
|
||||
)
|
||||
|
||||
if not user_message:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "missing_user_message"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# S29: Debug-level structured log — metadata only, never raw prompt content.
|
||||
logger.debug(
|
||||
"llm_chat: has_system=%s msg_len=%d temperature=%.2f max_tokens=%d",
|
||||
bool(system),
|
||||
len(user_message),
|
||||
temperature,
|
||||
max_tokens,
|
||||
)
|
||||
|
||||
try:
|
||||
with request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
client = LLMClient()
|
||||
|
||||
def _run():
|
||||
return client.complete(
|
||||
system=system,
|
||||
user_message=user_message,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
result = await run_in_thread(_run)
|
||||
text = ""
|
||||
if isinstance(result, dict):
|
||||
text = result.get("text") or ""
|
||||
return web.json_response(
|
||||
{"ok": True, "tenant_id": tenant.tenant_id, "text": text}
|
||||
)
|
||||
except TenantBoundaryError as e:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": e.code, "message": str(e)},
|
||||
status=403,
|
||||
)
|
||||
except ValueError as e:
|
||||
# Common: missing API key for selected provider
|
||||
return web.json_response(
|
||||
{"ok": False, "error": str(e)},
|
||||
status=400,
|
||||
)
|
||||
except ProviderHTTPError as e:
|
||||
# IMPORTANT (recurring support issue):
|
||||
# Do not swallow provider errors into a generic "llm_request_failed" without context.
|
||||
# The connector can safely surface *redacted* provider messages (no prompt content)
|
||||
# so users can fix misconfiguration (401/403/429, SSRF allowlist, etc.) quickly.
|
||||
payload = {
|
||||
"ok": False,
|
||||
"error": f"{e.provider} HTTP {e.status_code}: {e.message}",
|
||||
"provider": e.provider,
|
||||
"status_code": e.status_code,
|
||||
}
|
||||
if getattr(e, "retry_after", None):
|
||||
payload["retry_after"] = e.retry_after
|
||||
return web.json_response(payload, status=e.status_code)
|
||||
except Exception as e:
|
||||
# S29: Redact exception message to prevent accidental prompt content leakage.
|
||||
# Downgraded from error → warning (non-actionable for operators when provider-specific).
|
||||
try:
|
||||
from services.redaction import redact_text # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
from ..services.redaction import redact_text
|
||||
except ImportError:
|
||||
redact_text = str # type: ignore
|
||||
logger.warning(
|
||||
"LLM chat request failed: %s: %s",
|
||||
type(e).__name__,
|
||||
redact_text(str(e)),
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "llm_request_failed"},
|
||||
status=500,
|
||||
)
|
||||
# CRITICAL: owned implementation performs require_admin_token( before provider access.
|
||||
return await llm_chat_response(request, _handler_dependencies())
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Owned LLM connection-test and chat handler implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .config_projection_handlers import ConfigHandlerDependencies
|
||||
|
||||
|
||||
async def llm_test_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
|
||||
"""Run the existing tenant-scoped, audited LLM connection test."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
try:
|
||||
from ..services.async_utils import run_in_thread
|
||||
except ImportError:
|
||||
from services.async_utils import run_in_thread
|
||||
admin_token_configured = bool(deps.get_admin_token())
|
||||
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
|
||||
if response:
|
||||
return response
|
||||
if not deps.check_rate_limit(request, "admin"):
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=deps.web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
token_info = deps.resolve_token_info(request)
|
||||
allowed, error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
deps.emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target="llm",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": error or "unauthorized"},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": error or "Unauthorized"}, status=403
|
||||
)
|
||||
try:
|
||||
with deps.request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
try:
|
||||
body = await request.json()
|
||||
if body is None:
|
||||
body = {}
|
||||
except Exception:
|
||||
body = {}
|
||||
if body and not isinstance(body, dict):
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "Expected JSON object body (or empty body)"},
|
||||
status=400,
|
||||
)
|
||||
provider = (
|
||||
body.get("provider") if isinstance(body.get("provider"), str) else None
|
||||
)
|
||||
model = body.get("model") if isinstance(body.get("model"), str) else None
|
||||
base_url = (
|
||||
body.get("base_url") if isinstance(body.get("base_url"), str) else None
|
||||
)
|
||||
timeout_val = body.get("timeout_sec")
|
||||
timeout_sec = None
|
||||
if (
|
||||
isinstance(timeout_val, (int, float, str))
|
||||
and str(timeout_val).strip() != ""
|
||||
):
|
||||
try:
|
||||
timeout_sec = int(timeout_val)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "timeout_sec must be an integer"},
|
||||
status=400,
|
||||
)
|
||||
retries_val = body.get("max_retries")
|
||||
max_retries = None
|
||||
if (
|
||||
isinstance(retries_val, (int, float, str))
|
||||
and str(retries_val).strip() != ""
|
||||
):
|
||||
try:
|
||||
max_retries = int(retries_val)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "max_retries must be an integer"},
|
||||
status=400,
|
||||
)
|
||||
client = deps.llm_client(
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
timeout=timeout_sec,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
result = await run_in_thread(
|
||||
client.complete,
|
||||
system="You are a test assistant.",
|
||||
user_message="Respond with exactly: OK",
|
||||
max_tokens=10,
|
||||
)
|
||||
if result and "text" in result:
|
||||
deps.emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target=f"{client.provider}:{client.model}",
|
||||
outcome="allow",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": client.provider,
|
||||
"model": client.model,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"message": "Connection successful",
|
||||
"response": result["text"].strip(),
|
||||
"provider": client.provider,
|
||||
"model": client.model,
|
||||
}
|
||||
)
|
||||
deps.emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target=f"{client.provider}:{client.model}",
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": client.provider,
|
||||
"model": client.model,
|
||||
"error": "Empty response",
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "Empty or invalid response from LLM"}
|
||||
)
|
||||
except deps.tenant_boundary_error as exc:
|
||||
deps.emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target="llm",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": exc.code},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
|
||||
)
|
||||
except Exception as exc:
|
||||
deps.logger.error("LLM test failed (error_type=%s)", type(exc).__name__)
|
||||
deps.emit_audit_event(
|
||||
action="llm.test_connection",
|
||||
target="llm",
|
||||
outcome="error",
|
||||
token_info=token_info,
|
||||
status_code=500,
|
||||
details={"error": "llm_test_failed"},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "llm_test_failed"}, status=500
|
||||
)
|
||||
|
||||
|
||||
async def llm_chat_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
|
||||
"""Run server-side tenant-scoped chat without logging prompt content."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
try:
|
||||
from ..services.async_utils import run_in_thread
|
||||
except ImportError:
|
||||
from services.async_utils import run_in_thread
|
||||
try:
|
||||
from ..services.provider_errors import ProviderHTTPError
|
||||
except ImportError:
|
||||
from services.provider_errors import ProviderHTTPError
|
||||
admin_token_configured = bool(deps.get_admin_token())
|
||||
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
|
||||
if response:
|
||||
return response
|
||||
if not deps.check_rate_limit(request, "admin"):
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=deps.web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
token_info = deps.resolve_token_info(request)
|
||||
allowed, error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": error or "Unauthorized"}, status=403
|
||||
)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "Expected JSON object body"}, status=400
|
||||
)
|
||||
system = body.get("system") if isinstance(body.get("system"), str) else ""
|
||||
user_message = (
|
||||
body.get("user_message")
|
||||
if isinstance(body.get("user_message"), str)
|
||||
else body.get("message") if isinstance(body.get("message"), str) else ""
|
||||
)
|
||||
temperature = (
|
||||
body.get("temperature")
|
||||
if isinstance(body.get("temperature"), (int, float))
|
||||
else 0.7
|
||||
)
|
||||
max_tokens = (
|
||||
body.get("max_tokens") if isinstance(body.get("max_tokens"), int) else 1024
|
||||
)
|
||||
if not user_message:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "missing_user_message"}, status=400
|
||||
)
|
||||
deps.logger.debug(
|
||||
"llm_chat: has_system=%s msg_len=%d temperature=%.2f max_tokens=%d",
|
||||
bool(system),
|
||||
len(user_message),
|
||||
temperature,
|
||||
max_tokens,
|
||||
)
|
||||
try:
|
||||
with deps.request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
client = deps.llm_client()
|
||||
|
||||
def _run():
|
||||
return client.complete(
|
||||
system=system,
|
||||
user_message=user_message,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
result = await run_in_thread(_run)
|
||||
text = result.get("text") or "" if isinstance(result, dict) else ""
|
||||
return deps.web.json_response(
|
||||
{"ok": True, "tenant_id": tenant.tenant_id, "text": text}
|
||||
)
|
||||
except deps.tenant_boundary_error as exc:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
|
||||
)
|
||||
except ValueError as exc:
|
||||
return deps.web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
except ProviderHTTPError as exc:
|
||||
payload = {
|
||||
"ok": False,
|
||||
"error": f"{exc.provider} HTTP {exc.status_code}: {exc.message}",
|
||||
"provider": exc.provider,
|
||||
"status_code": exc.status_code,
|
||||
}
|
||||
if getattr(exc, "retry_after", None):
|
||||
payload["retry_after"] = exc.retry_after
|
||||
return deps.web.json_response(payload, status=exc.status_code)
|
||||
except Exception as exc:
|
||||
deps.logger.warning(
|
||||
"LLM chat request failed: ***REDACTED*** (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "llm_request_failed"}, status=500
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Owned remote model-discovery handler implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .config_projection_handlers import ConfigHandlerDependencies
|
||||
|
||||
|
||||
async def llm_models_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
|
||||
"""Serve tenant-isolated bounded provider model discovery."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
if not deps.check_rate_limit(request, "admin"):
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=deps.web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
token_info = deps.resolve_token_info(request)
|
||||
try:
|
||||
with deps.request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
allowed, error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
deps.emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"reason": error or "unauthorized",
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": error or "Unauthorized"}, status=403
|
||||
)
|
||||
allow_remote = (
|
||||
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
|
||||
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
|
||||
or ""
|
||||
).lower()
|
||||
if allow_remote not in ("1", "true", "yes", "on"):
|
||||
remote = request.remote or ""
|
||||
if not deps.is_loopback_client(remote):
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
provider_override = (request.query.get("provider") or "").strip().lower()
|
||||
effective, _sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
|
||||
try:
|
||||
target = deps.resolve_model_list_target(
|
||||
provider_override, effective, tenant.tenant_id
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": str(exc)}, status=400
|
||||
)
|
||||
cached_entry = deps.model_cache_get(target.cache_key)
|
||||
if cached_entry:
|
||||
_timestamp, models = cached_entry
|
||||
if isinstance(models, list):
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
# CRITICAL: local providers intentionally work without API keys.
|
||||
if target.requires_api_key and not target.api_key:
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"No API key configured for provider '{target.provider}'.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
try:
|
||||
controls = deps.get_llm_egress_controls(
|
||||
target.provider,
|
||||
target.base_url,
|
||||
allow_private_network=target.allow_private_network,
|
||||
)
|
||||
deps.validate_model_list_target(
|
||||
target,
|
||||
controls,
|
||||
allow_insecure_base_url=deps.llm_insecure_override_enabled(),
|
||||
)
|
||||
except Exception as exc:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": deps.format_llm_ssrf_error(exc)},
|
||||
status=403,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
from ..services.safe_io import SSRFError
|
||||
except ImportError:
|
||||
from services.safe_io import SSRFError
|
||||
|
||||
models = deps.fetch_remote_model_list(
|
||||
target,
|
||||
controls,
|
||||
pack_version=deps.pack_version,
|
||||
allow_insecure_base_url=deps.llm_insecure_override_enabled(),
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": False,
|
||||
}
|
||||
)
|
||||
except SSRFError as exc:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": deps.format_llm_ssrf_error(exc)},
|
||||
status=403,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
error_text = str(exc)
|
||||
if "HTTP" in error_text:
|
||||
stale = deps.get_stale_cached_models(target.cache_key)
|
||||
if stale:
|
||||
_timestamp, models = stale
|
||||
warning = f"Using cached list (refresh failed: {error_text})"
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": f"Upstream error: {error_text}"},
|
||||
status=502,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
stale = deps.get_stale_cached_models(target.cache_key)
|
||||
if stale:
|
||||
deps.logger.warning(
|
||||
"Model list refresh failed, serving cached list: %s", exc
|
||||
)
|
||||
_timestamp, models = stale
|
||||
warning = f"Using cached list (refresh failed: {exc!s})"
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"provider": target.provider,
|
||||
"models": models,
|
||||
"cached": True,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
deps.logger.exception("Failed to fetch model list")
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": str(exc)}, status=500
|
||||
)
|
||||
except deps.tenant_boundary_error as exc:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
|
||||
)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Owned config projection and mutation handler implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigHandlerDependencies:
|
||||
web: Any
|
||||
logger: Any
|
||||
provider_catalog: Any
|
||||
pack_version: Any
|
||||
require_observability_access: Any
|
||||
require_admin_token: Any
|
||||
require_same_origin_if_no_token: Any
|
||||
resolve_token_info: Any
|
||||
emit_audit_event: Any
|
||||
check_rate_limit: Any
|
||||
build_rate_limit_response: Any
|
||||
get_client_ip: Any
|
||||
is_loopback: Any
|
||||
get_admin_token: Any
|
||||
get_apply_semantics: Any
|
||||
get_effective_config: Any
|
||||
get_llm_egress_controls: Any
|
||||
get_runtime_guardrails: Any
|
||||
get_settings_schema: Any
|
||||
is_loopback_client: Any
|
||||
update_config: Any
|
||||
tenant_boundary_error: Any
|
||||
request_tenant_scope: Any
|
||||
runtime_only_code: Any
|
||||
payload_contains_runtime_guardrails: Any
|
||||
model_cache_get: Any
|
||||
format_llm_ssrf_error: Any
|
||||
llm_insecure_override_enabled: Any
|
||||
fetch_remote_model_list: Any
|
||||
get_stale_cached_models: Any
|
||||
resolve_model_list_target: Any
|
||||
validate_model_list_target: Any
|
||||
llm_client: Any
|
||||
|
||||
|
||||
async def config_get_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
|
||||
"""Return the tenant-scoped effective configuration projection."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
allowed, error = deps.require_observability_access(request)
|
||||
if not allowed:
|
||||
return deps.web.json_response({"ok": False, "error": error}, status=403)
|
||||
if not deps.check_rate_limit(request, "admin"):
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=deps.web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
token_info = deps.resolve_token_info(request)
|
||||
try:
|
||||
with deps.request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
effective, sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
|
||||
guardrails = deps.get_runtime_guardrails()
|
||||
if guardrails.get("status") != "ok":
|
||||
deps.emit_audit_event(
|
||||
action="runtime.guardrails",
|
||||
target="runtime_guardrails",
|
||||
outcome="warn",
|
||||
token_info=token_info,
|
||||
status_code=200,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"code": guardrails.get("code"),
|
||||
"violations": guardrails.get("violations", []),
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"config": effective,
|
||||
"sources": sources,
|
||||
"runtime_guardrails": guardrails,
|
||||
"providers": deps.provider_catalog,
|
||||
"schema": deps.get_settings_schema(),
|
||||
"write_enabled": True,
|
||||
}
|
||||
)
|
||||
except deps.tenant_boundary_error as exc:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
|
||||
)
|
||||
except Exception as exc:
|
||||
deps.logger.error("Error getting config (error_type=%s)", type(exc).__name__)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "config_read_failed"}, status=500
|
||||
)
|
||||
|
||||
|
||||
async def config_put_response(request: Any, deps: ConfigHandlerDependencies) -> Any:
|
||||
"""Validate and atomically apply tenant-scoped non-secret config updates."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
admin_token_configured = bool(deps.get_admin_token())
|
||||
response = deps.require_same_origin_if_no_token(request, admin_token_configured)
|
||||
if response:
|
||||
return response
|
||||
if not deps.check_rate_limit(request, "admin"):
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=deps.web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
token_info = deps.resolve_token_info(request)
|
||||
allowed, error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
deps.emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": error or "admin_token_required"},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": error or "Unauthorized"}, status=403
|
||||
)
|
||||
|
||||
allow_remote = (
|
||||
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
|
||||
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
|
||||
or ""
|
||||
).lower()
|
||||
if allow_remote not in ("1", "true", "yes", "on"):
|
||||
remote = deps.get_client_ip(request)
|
||||
if not deps.is_loopback(remote):
|
||||
deps.emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": "remote_admin_denied", "remote": remote},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Remote admin access denied. Set OPENCLAW_ALLOW_REMOTE_ADMIN=1 (or legacy MOLTBOT_ALLOW_REMOTE_ADMIN=1) to allow.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
try:
|
||||
with deps.request_tenant_scope(
|
||||
request=request, token_info=token_info, allow_default_when_missing=True
|
||||
) as tenant:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
if deps.payload_contains_runtime_guardrails(body):
|
||||
deps.emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=400,
|
||||
details={
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"reason": "runtime_guardrails_runtime_only",
|
||||
"code": deps.runtime_only_code,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "runtime_guardrails are runtime-only (ENV-driven) and cannot be persisted via /config",
|
||||
"code": deps.runtime_only_code,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
updates = body.get("llm", body)
|
||||
if not isinstance(updates, dict):
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "Expected object with config fields"},
|
||||
status=400,
|
||||
)
|
||||
success, errors = deps.update_config(updates, tenant_id=tenant.tenant_id)
|
||||
deps.emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="allow" if success else "error",
|
||||
token_info=token_info,
|
||||
status_code=200 if success else 400,
|
||||
details=(
|
||||
{"tenant_id": tenant.tenant_id, "errors": errors}
|
||||
if errors
|
||||
else {"tenant_id": tenant.tenant_id}
|
||||
),
|
||||
request=request,
|
||||
)
|
||||
if not success:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "errors": errors}, status=400
|
||||
)
|
||||
effective, sources = deps.get_effective_config(tenant_id=tenant.tenant_id)
|
||||
apply_info = deps.get_apply_semantics(list(updates.keys()))
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"tenant_id": tenant.tenant_id,
|
||||
"config": effective,
|
||||
"sources": sources,
|
||||
"apply": apply_info,
|
||||
}
|
||||
)
|
||||
except deps.tenant_boundary_error as exc:
|
||||
deps.emit_audit_event(
|
||||
action="config.update",
|
||||
target="config.json",
|
||||
outcome="deny",
|
||||
token_info=token_info,
|
||||
status_code=403,
|
||||
details={"reason": exc.code},
|
||||
request=request,
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": exc.code, "message": str(exc)}, status=403
|
||||
)
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Owned observability and jobs handler implementations for the API facade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteHandlerDependencies:
|
||||
web: Any
|
||||
pack_name: Any
|
||||
pack_version: Any
|
||||
pack_start_time: Any
|
||||
log_file: Any
|
||||
metrics: Any
|
||||
tail_log: Any
|
||||
require_observability_access: Any
|
||||
require_admin_token: Any
|
||||
check_rate_limit: Any
|
||||
build_rate_limit_response: Any
|
||||
trace_store: Any
|
||||
get_executor_diagnostics: Any
|
||||
redact_text: Any
|
||||
check_dependency: Callable[[str], bool]
|
||||
resolve_token_info: Any
|
||||
emit_audit_event: Any
|
||||
jobs_request_tenant_scope: Any
|
||||
normalize_jobs_query: Any
|
||||
build_jobs_audit_details: Any
|
||||
safe_job_audit_outcomes: Any
|
||||
jobs_security_error: Any
|
||||
tenant_boundary_error: Any
|
||||
jobs_host_contract_unsupported: Any
|
||||
jobs_backend_unavailable: Any
|
||||
read_jobs: Any
|
||||
ensure_observability_deps_ready: Any
|
||||
|
||||
|
||||
def ensure_observability_deps_ready(
|
||||
deps: RouteHandlerDependencies,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Reject partially initialized observability handlers deterministically."""
|
||||
|
||||
missing: list[str] = []
|
||||
if not callable(deps.require_observability_access):
|
||||
missing.append("require_observability_access")
|
||||
if not callable(deps.check_rate_limit):
|
||||
missing.append("check_rate_limit")
|
||||
if not callable(deps.tail_log):
|
||||
missing.append("tail_log")
|
||||
if missing:
|
||||
return (
|
||||
False,
|
||||
"Backend not fully initialized (missing route dependencies: "
|
||||
+ ", ".join(missing)
|
||||
+ ").",
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
async def health_response(request: Any, deps: RouteHandlerDependencies) -> Any:
|
||||
"""Build the existing partial-failure-tolerant health response."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
try:
|
||||
from ..services.llm_client import LLMClient
|
||||
from ..services.providers.keys import requires_api_key
|
||||
except ImportError:
|
||||
from services.llm_client import LLMClient
|
||||
from services.providers.keys import requires_api_key
|
||||
|
||||
uptime = time.time() - deps.pack_start_time
|
||||
provider_info = {
|
||||
"provider": "unknown",
|
||||
"key_configured": False,
|
||||
"model": "unknown",
|
||||
"base_url": None,
|
||||
"api_type": None,
|
||||
}
|
||||
key_required = True
|
||||
try:
|
||||
client = LLMClient()
|
||||
provider_info = client.get_provider_summary()
|
||||
key_required = requires_api_key(provider_info.get("provider", "unknown"))
|
||||
except Exception:
|
||||
provider_info = {
|
||||
"provider": "unknown",
|
||||
"key_configured": False,
|
||||
"model": "unknown",
|
||||
"base_url": None,
|
||||
"api_type": None,
|
||||
}
|
||||
key_required = True
|
||||
|
||||
try:
|
||||
from ..services.access_control import is_loopback
|
||||
|
||||
_ = is_loopback
|
||||
|
||||
token_val = (
|
||||
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
||||
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
||||
or ""
|
||||
).strip()
|
||||
token_configured = bool(token_val)
|
||||
except ImportError:
|
||||
from services.access_control import is_loopback
|
||||
|
||||
_ = is_loopback
|
||||
|
||||
token_val = (
|
||||
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
||||
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
||||
or ""
|
||||
).strip()
|
||||
token_configured = bool(token_val)
|
||||
policy_mode = "token" if token_configured else "loopback_only"
|
||||
|
||||
try:
|
||||
metrics_snapshot = deps.metrics.get_snapshot()
|
||||
except Exception:
|
||||
metrics_snapshot = {"errors_captured": 0, "logs_processed": 0}
|
||||
try:
|
||||
executor_snapshot = deps.get_executor_diagnostics() or {}
|
||||
except Exception:
|
||||
executor_snapshot = {}
|
||||
try:
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.startup_lifecycle import get_startup_diagnostics
|
||||
else:
|
||||
from services.startup_lifecycle import get_startup_diagnostics
|
||||
startup_diagnostics = get_startup_diagnostics()
|
||||
except Exception:
|
||||
# SECURITY: keep the public fallback deterministic and content-free even when
|
||||
# startup diagnostics cannot be imported.
|
||||
startup_diagnostics = {
|
||||
"schema_version": 1,
|
||||
"phase": "package_import",
|
||||
"state": "fatal",
|
||||
"reason_code": "bootstrap_import_failed",
|
||||
"ready": False,
|
||||
"degraded": False,
|
||||
"fatal": True,
|
||||
"attempt": 0,
|
||||
"max_attempts": 0,
|
||||
"elapsed_ms": 0,
|
||||
"phase_elapsed_ms": 0,
|
||||
"ready_elapsed_ms": None,
|
||||
"warmups": [],
|
||||
}
|
||||
|
||||
job_stats = {}
|
||||
try:
|
||||
from ..services.job_events import get_job_event_store
|
||||
|
||||
job_stats = get_job_event_store().stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
control_plane_info = {}
|
||||
runtime_profile = "minimal"
|
||||
try:
|
||||
try:
|
||||
from ..services.capabilities import _get_control_plane_info
|
||||
from ..services.runtime_profile import get_runtime_profile
|
||||
except ImportError:
|
||||
from services.capabilities import _get_control_plane_info
|
||||
from services.runtime_profile import get_runtime_profile
|
||||
control_plane_info = _get_control_plane_info()
|
||||
runtime_profile = get_runtime_profile().value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"pack": {
|
||||
"name": deps.pack_name,
|
||||
"version": deps.pack_version,
|
||||
"dependencies": {
|
||||
"aiohttp": deps.check_dependency("aiohttp"),
|
||||
"watchdog": deps.check_dependency("watchdog"),
|
||||
},
|
||||
},
|
||||
"uptime_sec": uptime,
|
||||
"config": {
|
||||
"provider": provider_info.get("provider"),
|
||||
"model": provider_info.get("model"),
|
||||
"base_url": provider_info.get("base_url"),
|
||||
"api_type": provider_info.get("api_type"),
|
||||
"llm_key_configured": provider_info.get("key_configured", False),
|
||||
"llm_key_required": key_required,
|
||||
},
|
||||
"stats": {
|
||||
"errors_captured": metrics_snapshot["errors_captured"],
|
||||
"logs_processed": metrics_snapshot["logs_processed"],
|
||||
"executors": executor_snapshot,
|
||||
"observability": job_stats,
|
||||
},
|
||||
"startup": startup_diagnostics,
|
||||
"access_policy": {
|
||||
"observability": policy_mode,
|
||||
"token_configured": token_configured,
|
||||
},
|
||||
"control_plane": control_plane_info,
|
||||
"runtime_profile": runtime_profile,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def logs_tail_response(request: Any, deps: RouteHandlerDependencies) -> Any:
|
||||
"""Authorize, bound, filter, and redact the log-tail response."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
ok, init_error = deps.ensure_observability_deps_ready()
|
||||
if not ok:
|
||||
return deps.web.json_response({"ok": False, "error": init_error}, status=500)
|
||||
allowed, error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
return deps.web.json_response({"ok": False, "error": error}, status=403)
|
||||
if not deps.check_rate_limit(request, "logs"):
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"logs",
|
||||
web_module=deps.web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
try:
|
||||
line_count = 50
|
||||
val_n = request.query.get("n")
|
||||
val_lines = request.query.get("lines")
|
||||
target_val = val_n if val_n is not None else val_lines
|
||||
if target_val:
|
||||
with suppress(ValueError):
|
||||
line_count = int(target_val)
|
||||
line_count = min(max(line_count, 1), 500)
|
||||
trace_id_filter = request.query.get("trace_id")
|
||||
prompt_id_filter = request.query.get("prompt_id")
|
||||
content = deps.tail_log(deps.log_file, line_count)
|
||||
if trace_id_filter or prompt_id_filter:
|
||||
content = [
|
||||
line
|
||||
for line in content
|
||||
if (trace_id_filter and trace_id_filter in line)
|
||||
or (prompt_id_filter and prompt_id_filter in line)
|
||||
]
|
||||
if deps.redact_text:
|
||||
content = [deps.redact_text(line) for line in content]
|
||||
max_bytes = 100_000
|
||||
if sum(len(line.encode("utf-8")) for line in content) > max_bytes:
|
||||
truncated: list[str] = []
|
||||
current_bytes = 0
|
||||
for line in reversed(content):
|
||||
line_bytes = len(line.encode("utf-8"))
|
||||
if current_bytes + line_bytes > max_bytes:
|
||||
break
|
||||
truncated.insert(0, line)
|
||||
current_bytes += line_bytes
|
||||
content = truncated
|
||||
return deps.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"content": content,
|
||||
"filtered": bool(trace_id_filter or prompt_id_filter),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return deps.web.json_response({"ok": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
def emit_jobs_list_audit(
|
||||
deps: RouteHandlerDependencies,
|
||||
*,
|
||||
request: Any,
|
||||
token_info: Any,
|
||||
outcome: str,
|
||||
status_code: int,
|
||||
reason: str,
|
||||
**counts: Any,
|
||||
) -> None:
|
||||
safe_outcome = outcome if outcome in deps.safe_job_audit_outcomes else "error"
|
||||
deps.emit_audit_event(
|
||||
action="jobs.list",
|
||||
target="jobs",
|
||||
outcome=safe_outcome,
|
||||
token_info=token_info,
|
||||
status_code=status_code,
|
||||
details=deps.build_jobs_audit_details(reason, **counts),
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
async def jobs_response(request: Any, deps: RouteHandlerDependencies) -> Any:
|
||||
"""Serve the R213 bounded jobs read model behind its security transaction."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
token_info = deps.resolve_token_info(request)
|
||||
if not deps.check_rate_limit(request, "admin"):
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="rate_limit",
|
||||
status_code=429,
|
||||
reason="jobs_rate_limited",
|
||||
)
|
||||
return deps.build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=deps.web,
|
||||
error="jobs_rate_limited",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# CRITICAL: metadata is descriptive; this guard must precede queue/history access.
|
||||
allowed, _error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
reason="jobs_admin_required",
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "jobs_admin_required"}, status=403
|
||||
)
|
||||
try:
|
||||
with deps.jobs_request_tenant_scope(request, token_info) as tenant_context:
|
||||
query = deps.normalize_jobs_query(request.query)
|
||||
body = deps.read_jobs(query, tenant_id=tenant_context.tenant_id)
|
||||
scan = body["scan"]
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="allow",
|
||||
status_code=200,
|
||||
reason="jobs_listed",
|
||||
returned_count=len(body["jobs"]),
|
||||
excluded_count=scan["excluded"],
|
||||
malformed_count=scan["malformed"],
|
||||
)
|
||||
except deps.tenant_boundary_error as exc:
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
reason=exc.code,
|
||||
)
|
||||
return deps.web.json_response({"ok": False, "error": exc.code}, status=403)
|
||||
except deps.jobs_security_error:
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="error",
|
||||
status_code=400,
|
||||
reason="jobs_query_invalid",
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "jobs_query_invalid"}, status=400
|
||||
)
|
||||
except deps.jobs_host_contract_unsupported:
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="unsupported",
|
||||
status_code=501,
|
||||
reason="jobs_host_contract_unsupported",
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "jobs_host_contract_unsupported"}, status=501
|
||||
)
|
||||
except deps.jobs_backend_unavailable:
|
||||
emit_jobs_list_audit(
|
||||
deps,
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="error",
|
||||
status_code=503,
|
||||
reason="jobs_backend_unavailable",
|
||||
)
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "jobs_backend_unavailable"}, status=503
|
||||
)
|
||||
return deps.web.json_response(body)
|
||||
|
||||
|
||||
async def trace_response(request: Any, deps: RouteHandlerDependencies) -> Any:
|
||||
"""Authorize and return the redacted operator trace projection."""
|
||||
|
||||
if deps.web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
ok, init_error = deps.ensure_observability_deps_ready()
|
||||
if not ok:
|
||||
return deps.web.json_response({"ok": False, "error": init_error}, status=500)
|
||||
allowed, error = deps.require_admin_token(request)
|
||||
if not allowed:
|
||||
return deps.web.json_response({"ok": False, "error": error}, status=403)
|
||||
prompt_id = request.match_info.get("prompt_id")
|
||||
if not prompt_id:
|
||||
return deps.web.json_response(
|
||||
{"ok": False, "error": "missing_prompt_id"}, status=400
|
||||
)
|
||||
record = deps.trace_store.get(prompt_id)
|
||||
if not record:
|
||||
return deps.web.json_response({"ok": False, "error": "not_found"}, status=404)
|
||||
trace_data = record.to_dict()
|
||||
try:
|
||||
from ..services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
sanitize_operator_payload,
|
||||
)
|
||||
from ..services.redaction import redact_json
|
||||
except ImportError:
|
||||
from services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
sanitize_operator_payload,
|
||||
)
|
||||
from services.redaction import redact_json
|
||||
if redact_json:
|
||||
trace_data = redact_json(trace_data)
|
||||
reveal = resolve_reasoning_reveal(request, admin_authorized=allowed)
|
||||
audit_reasoning_reveal(request, target="trace.get", decision=reveal)
|
||||
trace_data = sanitize_operator_payload(
|
||||
trace_data, include_reasoning=reveal["allowed"]
|
||||
)
|
||||
return deps.web.json_response({"ok": True, "trace": trace_data})
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Owned PromptServer route registration and startup orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteRegistrationDependencies:
|
||||
build_core_route_specs: Callable[..., Any]
|
||||
build_assist_route_specs: Callable[..., Any]
|
||||
build_connector_installation_route_specs: Callable[..., Any]
|
||||
build_pack_route_specs: Callable[..., Any]
|
||||
register_route_family: Callable[..., None]
|
||||
register_dual_route: Callable[..., None]
|
||||
core_handlers: dict[str, Any]
|
||||
assist: Any
|
||||
connector_installation_handlers: dict[str, Any] | None
|
||||
run_mae_startup_gate: Callable[[Any], None]
|
||||
|
||||
|
||||
def register_dual_route(
|
||||
server: Any,
|
||||
method: str,
|
||||
path: str,
|
||||
handler: Any,
|
||||
*,
|
||||
metrics: Any = None,
|
||||
legacy_headers_builder: Any = None,
|
||||
) -> None:
|
||||
"""Register PromptServer and direct aliases with one legacy wrapper."""
|
||||
|
||||
if not callable(handler):
|
||||
print(
|
||||
f"[OpenClaw] Warning: Skipping route {method} {path} because handler is missing (None)."
|
||||
)
|
||||
return
|
||||
actual_handler = handler
|
||||
if path.startswith("/moltbot"):
|
||||
|
||||
@wraps(handler)
|
||||
async def _deprecated_handler(request: Any) -> Any:
|
||||
try:
|
||||
if metrics:
|
||||
metrics.inc("legacy_api_hits")
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
|
||||
)
|
||||
response = await handler(request)
|
||||
if legacy_headers_builder:
|
||||
headers = legacy_headers_builder(getattr(request, "path", path))
|
||||
response_headers = getattr(response, "headers", None)
|
||||
if (
|
||||
headers
|
||||
and response_headers is not None
|
||||
and hasattr(response_headers, "update")
|
||||
):
|
||||
response_headers.update(headers)
|
||||
return response
|
||||
|
||||
actual_handler = _deprecated_handler
|
||||
|
||||
registrar = (
|
||||
getattr(server.routes, method.lower(), None)
|
||||
if method in {"GET", "POST", "PUT", "DELETE"}
|
||||
else None
|
||||
)
|
||||
if registrar is not None:
|
||||
registrar(path)(actual_handler)
|
||||
if hasattr(server, "app") and hasattr(server.app, "router"):
|
||||
for target in (path, "/api" + path):
|
||||
try:
|
||||
# IMPORTANT: direct aliases must retain the same legacy wrapper.
|
||||
server.app.router.add_route(method, target, actual_handler)
|
||||
except RuntimeError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"[OpenClaw] Warning: Failed to register fallback route {target}: {exc}"
|
||||
)
|
||||
|
||||
|
||||
def run_mae_startup_gate(server: Any, resolve_profile: Callable[[], str]) -> None:
|
||||
"""Validate the registered OpenClaw route posture for the active profile."""
|
||||
|
||||
if not hasattr(server, "app"):
|
||||
return
|
||||
try:
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
generate_manifest,
|
||||
validate_mae_posture,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
generate_manifest,
|
||||
validate_mae_posture,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[OpenClaw] Warning: S60 MAE gate unavailable: {exc}")
|
||||
return
|
||||
profile = resolve_profile()
|
||||
manifest = generate_manifest(server.app)
|
||||
scoped_manifest = [
|
||||
entry for entry in manifest if _is_openclaw_managed_path(entry.get("path", ""))
|
||||
]
|
||||
ok, violations = validate_mae_posture(scoped_manifest, profile=profile)
|
||||
if ok:
|
||||
return
|
||||
message = "S60 MAE posture validation failed:\n" + "\n".join(
|
||||
f"- {item}" for item in violations
|
||||
)
|
||||
if profile in {"public", "hardened"}:
|
||||
raise RuntimeError(message)
|
||||
print(f"[OpenClaw] Warning: {message}")
|
||||
|
||||
|
||||
def _is_openclaw_managed_path(path: str) -> bool:
|
||||
if not isinstance(path, str):
|
||||
return False
|
||||
return path.startswith(
|
||||
(
|
||||
"/openclaw",
|
||||
"/moltbot",
|
||||
"/api/openclaw",
|
||||
"/api/moltbot",
|
||||
"/bridge",
|
||||
"/api/bridge",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _register_bridge(server: Any) -> None:
|
||||
try:
|
||||
try:
|
||||
from ..api.bridge import register_bridge_routes
|
||||
from ..services.modules import ModuleCapability, is_module_enabled
|
||||
except (ImportError, ValueError):
|
||||
from api.bridge import register_bridge_routes
|
||||
from services.modules import ModuleCapability, is_module_enabled
|
||||
if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE):
|
||||
register_bridge_routes(server.app)
|
||||
print("[OpenClaw] Bridge routes registered")
|
||||
elif not is_module_enabled(ModuleCapability.BRIDGE):
|
||||
print("[OpenClaw] Bridge module disabled; skipping route registration")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def _register_packs(
|
||||
server: Any, prefixes: tuple[str, ...], deps: RouteRegistrationDependencies
|
||||
) -> None:
|
||||
try:
|
||||
try:
|
||||
from ..api.packs import PacksHandlers
|
||||
except (ImportError, ValueError):
|
||||
from api.packs import PacksHandlers
|
||||
try:
|
||||
from ..config import DATA_DIR
|
||||
except (ImportError, ValueError):
|
||||
from config import DATA_DIR
|
||||
packs = PacksHandlers(DATA_DIR)
|
||||
for prefix in prefixes:
|
||||
deps.register_route_family(
|
||||
server,
|
||||
deps.register_dual_route,
|
||||
deps.build_pack_route_specs(prefix, packs),
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def register_route_families(server: Any, deps: RouteRegistrationDependencies) -> None:
|
||||
"""Register all route families in the frozen R220 exposure order."""
|
||||
|
||||
prefixes = ("/openclaw", "/moltbot")
|
||||
for prefix in prefixes:
|
||||
deps.register_route_family(
|
||||
server,
|
||||
deps.register_dual_route,
|
||||
deps.build_core_route_specs(prefix, deps.core_handlers),
|
||||
)
|
||||
if deps.assist:
|
||||
for prefix in prefixes:
|
||||
deps.register_route_family(
|
||||
server,
|
||||
deps.register_dual_route,
|
||||
deps.build_assist_route_specs(prefix, deps.assist),
|
||||
)
|
||||
if deps.connector_installation_handlers is not None:
|
||||
for prefix in prefixes:
|
||||
deps.register_route_family(
|
||||
server,
|
||||
deps.register_dual_route,
|
||||
deps.build_connector_installation_route_specs(
|
||||
prefix, deps.connector_installation_handlers
|
||||
),
|
||||
)
|
||||
_register_bridge(server)
|
||||
deps.run_mae_startup_gate(server)
|
||||
_register_packs(server, prefixes, deps)
|
||||
+154
-590
@@ -7,10 +7,8 @@ Registers /openclaw/* endpoints (and legacy /moltbot/*) against ComfyUI PromptSe
|
||||
# Do not move this import or insert code above it, or ComfyUI route registration will fail.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.import_fallback import import_attrs_dual
|
||||
@@ -39,6 +37,46 @@ else:
|
||||
),
|
||||
)
|
||||
|
||||
(
|
||||
RouteHandlerDependencies,
|
||||
emit_jobs_list_audit,
|
||||
health_response,
|
||||
jobs_response,
|
||||
logs_tail_response,
|
||||
owned_ensure_observability_deps_ready,
|
||||
trace_response,
|
||||
) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.route_handlers",
|
||||
"api.route_handlers",
|
||||
(
|
||||
"RouteHandlerDependencies",
|
||||
"emit_jobs_list_audit",
|
||||
"health_response",
|
||||
"jobs_response",
|
||||
"logs_tail_response",
|
||||
"ensure_observability_deps_ready",
|
||||
"trace_response",
|
||||
),
|
||||
)
|
||||
|
||||
(
|
||||
RouteRegistrationDependencies,
|
||||
orchestrate_dual_route,
|
||||
register_route_families,
|
||||
run_mae_startup_gate,
|
||||
) = import_attrs_dual(
|
||||
__package__,
|
||||
"..api.route_orchestration",
|
||||
"api.route_orchestration",
|
||||
(
|
||||
"RouteRegistrationDependencies",
|
||||
"register_dual_route",
|
||||
"register_route_families",
|
||||
"run_mae_startup_gate",
|
||||
),
|
||||
)
|
||||
|
||||
# R98 / R64: Endpoint Metadata import via shared helper
|
||||
(
|
||||
AuthTier,
|
||||
@@ -403,27 +441,47 @@ def check_dependency(module_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _handler_dependencies():
|
||||
"""Capture facade patch seams for the owned route implementations."""
|
||||
|
||||
return RouteHandlerDependencies(
|
||||
web=web,
|
||||
pack_name=PACK_NAME,
|
||||
pack_version=PACK_VERSION,
|
||||
pack_start_time=PACK_START_TIME,
|
||||
log_file=LOG_FILE,
|
||||
metrics=metrics,
|
||||
tail_log=tail_log,
|
||||
require_observability_access=require_observability_access,
|
||||
require_admin_token=require_admin_token,
|
||||
check_rate_limit=check_rate_limit,
|
||||
build_rate_limit_response=build_rate_limit_response,
|
||||
trace_store=trace_store,
|
||||
get_executor_diagnostics=get_executor_diagnostics,
|
||||
redact_text=redact_text,
|
||||
check_dependency=check_dependency,
|
||||
resolve_token_info=resolve_token_info,
|
||||
emit_audit_event=emit_audit_event,
|
||||
jobs_request_tenant_scope=jobs_request_tenant_scope,
|
||||
normalize_jobs_query=normalize_jobs_query,
|
||||
build_jobs_audit_details=build_jobs_audit_details,
|
||||
safe_job_audit_outcomes=SAFE_JOB_AUDIT_OUTCOMES,
|
||||
jobs_security_error=JobsSecurityError,
|
||||
tenant_boundary_error=TenantBoundaryError,
|
||||
jobs_host_contract_unsupported=JobsHostContractUnsupported,
|
||||
jobs_backend_unavailable=JobsBackendUnavailable,
|
||||
read_jobs=read_jobs,
|
||||
ensure_observability_deps_ready=_ensure_observability_deps_ready,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_observability_deps_ready() -> tuple[bool, str | None]:
|
||||
"""
|
||||
Defensive guard against a recurring class of regressions:
|
||||
if the import block above is edited incorrectly, the module-level
|
||||
placeholders stay as None and handlers raise TypeError at runtime.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
if not callable(require_observability_access):
|
||||
missing.append("require_observability_access")
|
||||
if not callable(check_rate_limit):
|
||||
missing.append("check_rate_limit")
|
||||
if not callable(tail_log):
|
||||
missing.append("tail_log")
|
||||
if missing:
|
||||
return (
|
||||
False,
|
||||
"Backend not fully initialized (missing route dependencies: "
|
||||
+ ", ".join(missing)
|
||||
+ ").",
|
||||
)
|
||||
return True, None
|
||||
"""Preserve the established initialization-check patch seam."""
|
||||
|
||||
return cast(
|
||||
tuple[bool, str | None],
|
||||
owned_ensure_observability_deps_ready(_handler_dependencies()),
|
||||
)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -439,146 +497,7 @@ async def health_handler(request: web.Request) -> web.Response:
|
||||
GET /openclaw/health (legacy: /moltbot/health)
|
||||
Returns pack status, uptime, dependencies, config presence, and stats.
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
try:
|
||||
from ..services.llm_client import LLMClient
|
||||
from ..services.providers.keys import requires_api_key
|
||||
except ImportError:
|
||||
from services.llm_client import LLMClient
|
||||
from services.providers.keys import requires_api_key
|
||||
|
||||
uptime = time.time() - PACK_START_TIME
|
||||
|
||||
# Get provider info from LLMClient
|
||||
provider_info = {
|
||||
"provider": "unknown",
|
||||
"key_configured": False,
|
||||
"model": "unknown",
|
||||
"base_url": None,
|
||||
"api_type": None,
|
||||
}
|
||||
key_required = True
|
||||
try:
|
||||
client = LLMClient()
|
||||
provider_info = client.get_provider_summary()
|
||||
key_required = requires_api_key(provider_info.get("provider", "unknown"))
|
||||
except Exception:
|
||||
provider_info = {
|
||||
"provider": "unknown",
|
||||
"key_configured": False,
|
||||
"model": "unknown",
|
||||
"base_url": None,
|
||||
"api_type": None,
|
||||
}
|
||||
key_required = True
|
||||
|
||||
# S15: Access Policy Info
|
||||
try:
|
||||
from ..services.access_control import is_loopback
|
||||
|
||||
token_val = (
|
||||
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
||||
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
||||
or ""
|
||||
).strip()
|
||||
token_configured = bool(token_val)
|
||||
except ImportError:
|
||||
from services.access_control import is_loopback
|
||||
|
||||
token_val = (
|
||||
os.environ.get("OPENCLAW_OBSERVABILITY_TOKEN")
|
||||
or os.environ.get("MOLTBOT_OBSERVABILITY_TOKEN")
|
||||
or ""
|
||||
).strip()
|
||||
token_configured = bool(token_val)
|
||||
|
||||
# Determine basic policy state
|
||||
policy_mode = "token" if token_configured else "loopback_only"
|
||||
|
||||
# Metrics snapshot
|
||||
# Metrics snapshot (robust even if metrics implementation changes)
|
||||
try:
|
||||
m_snapshot = metrics.get_snapshot()
|
||||
except Exception:
|
||||
m_snapshot = {"errors_captured": 0, "logs_processed": 0}
|
||||
try:
|
||||
executor_snapshot = get_executor_diagnostics() or {}
|
||||
except Exception:
|
||||
executor_snapshot = {}
|
||||
|
||||
try:
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.startup_lifecycle import get_startup_diagnostics
|
||||
else:
|
||||
from services.startup_lifecycle import get_startup_diagnostics
|
||||
|
||||
startup_diagnostics = get_startup_diagnostics()
|
||||
except Exception:
|
||||
startup_diagnostics = {"state": "unknown", "ready": False, "warmups": {}}
|
||||
|
||||
# Job Event Store Stats (Backpressure)
|
||||
job_stats = {}
|
||||
try:
|
||||
from ..services.job_events import get_job_event_store
|
||||
|
||||
store = get_job_event_store()
|
||||
job_stats = store.stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# H3 (F55): Include control_plane info for frontend mode badge
|
||||
cp_info = {}
|
||||
runtime_prof = "minimal"
|
||||
try:
|
||||
try:
|
||||
from ..services.capabilities import _get_control_plane_info
|
||||
from ..services.runtime_profile import get_runtime_profile
|
||||
except ImportError:
|
||||
from services.capabilities import _get_control_plane_info
|
||||
from services.runtime_profile import get_runtime_profile
|
||||
cp_info = _get_control_plane_info()
|
||||
runtime_prof = get_runtime_profile().value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"pack": {
|
||||
"name": PACK_NAME,
|
||||
"version": PACK_VERSION,
|
||||
"dependencies": {
|
||||
"aiohttp": check_dependency("aiohttp"),
|
||||
"watchdog": check_dependency("watchdog"),
|
||||
},
|
||||
},
|
||||
"uptime_sec": uptime,
|
||||
"config": {
|
||||
"provider": provider_info.get("provider"),
|
||||
"model": provider_info.get("model"),
|
||||
"base_url": provider_info.get("base_url"),
|
||||
"api_type": provider_info.get("api_type"),
|
||||
"llm_key_configured": provider_info.get("key_configured", False),
|
||||
"llm_key_required": key_required,
|
||||
},
|
||||
"stats": {
|
||||
"errors_captured": m_snapshot["errors_captured"],
|
||||
"logs_processed": m_snapshot["logs_processed"],
|
||||
"executors": executor_snapshot, # R129
|
||||
"observability": job_stats, # R87
|
||||
},
|
||||
"startup": startup_diagnostics,
|
||||
# S15: Exposure Detection
|
||||
"access_policy": {
|
||||
"observability": policy_mode,
|
||||
"token_configured": token_configured,
|
||||
},
|
||||
# H3 (F55): Control plane mode for frontend badge
|
||||
"control_plane": cp_info,
|
||||
"runtime_profile": runtime_prof,
|
||||
}
|
||||
)
|
||||
return await health_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -591,90 +510,8 @@ async def health_handler(request: web.Request) -> web.Response:
|
||||
)
|
||||
async def logs_tail_handler(request: web.Request) -> web.Response:
|
||||
"""GET /moltbot/logs/tail - Returns the last N lines of the log file."""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
ok, init_error = _ensure_observability_deps_ready()
|
||||
if not ok:
|
||||
return web.json_response({"ok": False, "error": init_error}, status=500)
|
||||
# S34: Trace/Log data is high sensitivity -> Require Admin Token
|
||||
allowed, error = require_admin_token(request)
|
||||
if not allowed:
|
||||
return web.json_response({"ok": False, "error": error}, status=403)
|
||||
|
||||
# S17: Rate Limit
|
||||
if not check_rate_limit(request, "logs"):
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"logs",
|
||||
web_module=web,
|
||||
error="Rate limit exceeded",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Default 50 lines, max 500
|
||||
# Support both 'n' (internal preference) and 'lines' (legacy frontend)
|
||||
line_count = 50
|
||||
|
||||
val_n = request.query.get("n")
|
||||
val_lines = request.query.get("lines")
|
||||
|
||||
target_val = val_n if val_n is not None else val_lines
|
||||
|
||||
if target_val:
|
||||
try:
|
||||
line_count = int(target_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Cap at 500
|
||||
line_count = min(max(line_count, 1), 500)
|
||||
|
||||
# R31: Filter parameters
|
||||
trace_id_filter = request.query.get("trace_id")
|
||||
prompt_id_filter = request.query.get("prompt_id")
|
||||
|
||||
content = tail_log(LOG_FILE, line_count)
|
||||
|
||||
# R31: Apply filtering if requested
|
||||
if trace_id_filter or prompt_id_filter:
|
||||
filtered_content = []
|
||||
for line in content:
|
||||
# Simple substring match (case-sensitive for IDs)
|
||||
if trace_id_filter and trace_id_filter in line:
|
||||
filtered_content.append(line)
|
||||
elif prompt_id_filter and prompt_id_filter in line:
|
||||
filtered_content.append(line)
|
||||
content = filtered_content
|
||||
|
||||
# S24: Apply redaction to each line
|
||||
if redact_text:
|
||||
content = [redact_text(line) for line in content]
|
||||
|
||||
# R31: Enforce max bytes limit (100KB total)
|
||||
MAX_BYTES = 100_000
|
||||
total_bytes = sum(len(line.encode("utf-8")) for line in content)
|
||||
if total_bytes > MAX_BYTES:
|
||||
# Truncate from end to stay under limit
|
||||
truncated = []
|
||||
current_bytes = 0
|
||||
for line in reversed(content):
|
||||
line_bytes = len(line.encode("utf-8"))
|
||||
if current_bytes + line_bytes > MAX_BYTES:
|
||||
break
|
||||
truncated.insert(0, line)
|
||||
current_bytes += line_bytes
|
||||
content = truncated
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"content": content,
|
||||
"filtered": bool(trace_id_filter or prompt_id_filter),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
# CRITICAL: logs_tail_response performs require_admin_token( before log access.
|
||||
return await logs_tail_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
@@ -690,101 +527,8 @@ async def jobs_handler(request: web.Request) -> web.Response:
|
||||
GET /openclaw/jobs (legacy: /moltbot/jobs).
|
||||
This handler preserves the authorization and tenant boundary around the read model.
|
||||
"""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
token_info = resolve_token_info(request)
|
||||
|
||||
if not check_rate_limit(request, "admin"):
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="rate_limit",
|
||||
status_code=429,
|
||||
reason="jobs_rate_limited",
|
||||
)
|
||||
return build_rate_limit_response(
|
||||
request,
|
||||
"admin",
|
||||
web_module=web,
|
||||
error="jobs_rate_limited",
|
||||
include_ok=True,
|
||||
)
|
||||
|
||||
# CRITICAL: endpoint metadata is descriptive; this explicit guard is the
|
||||
# runtime boundary that must remain before any queue/history access.
|
||||
allowed, _error = require_admin_token(request)
|
||||
if not allowed:
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
reason="jobs_admin_required",
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "jobs_admin_required"}, status=403
|
||||
)
|
||||
|
||||
try:
|
||||
with jobs_request_tenant_scope(request, token_info) as tenant_context:
|
||||
query = normalize_jobs_query(request.query)
|
||||
body = read_jobs(query, tenant_id=tenant_context.tenant_id)
|
||||
scan = body["scan"]
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="allow",
|
||||
status_code=200,
|
||||
reason="jobs_listed",
|
||||
returned_count=len(body["jobs"]),
|
||||
excluded_count=scan["excluded"],
|
||||
malformed_count=scan["malformed"],
|
||||
)
|
||||
except TenantBoundaryError as exc:
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="deny",
|
||||
status_code=403,
|
||||
reason=exc.code,
|
||||
)
|
||||
return web.json_response({"ok": False, "error": exc.code}, status=403)
|
||||
except JobsSecurityError:
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="error",
|
||||
status_code=400,
|
||||
reason="jobs_query_invalid",
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "jobs_query_invalid"}, status=400
|
||||
)
|
||||
except JobsHostContractUnsupported:
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="unsupported",
|
||||
status_code=501,
|
||||
reason="jobs_host_contract_unsupported",
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "jobs_host_contract_unsupported"}, status=501
|
||||
)
|
||||
except JobsBackendUnavailable:
|
||||
_emit_jobs_list_audit(
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome="error",
|
||||
status_code=503,
|
||||
reason="jobs_backend_unavailable",
|
||||
)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "jobs_backend_unavailable"}, status=503
|
||||
)
|
||||
|
||||
return web.json_response(body)
|
||||
# CRITICAL: jobs_response performs require_admin_token( before queue/history access.
|
||||
return await jobs_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
def _emit_jobs_list_audit(
|
||||
@@ -796,17 +540,16 @@ def _emit_jobs_list_audit(
|
||||
reason: str,
|
||||
**counts,
|
||||
) -> None:
|
||||
"""Emit only content-free jobs audit dimensions."""
|
||||
"""Preserve the established facade seam with content-free dependency capture."""
|
||||
|
||||
safe_outcome = outcome if outcome in SAFE_JOB_AUDIT_OUTCOMES else "error"
|
||||
emit_audit_event(
|
||||
action="jobs.list",
|
||||
target="jobs",
|
||||
outcome=safe_outcome,
|
||||
token_info=token_info,
|
||||
status_code=status_code,
|
||||
details=build_jobs_audit_details(reason, **counts),
|
||||
emit_jobs_list_audit(
|
||||
_handler_dependencies(),
|
||||
request=request,
|
||||
token_info=token_info,
|
||||
outcome=outcome,
|
||||
status_code=status_code,
|
||||
reason=reason,
|
||||
**counts,
|
||||
)
|
||||
|
||||
|
||||
@@ -820,52 +563,8 @@ def _emit_jobs_list_audit(
|
||||
)
|
||||
async def trace_handler(request: web.Request) -> web.Response:
|
||||
"""GET /moltbot/trace/{prompt_id} - Returns trace_id and redacted timeline."""
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
ok, init_error = _ensure_observability_deps_ready()
|
||||
if not ok:
|
||||
return web.json_response({"ok": False, "error": init_error}, status=500)
|
||||
# S34: Trace/Log data is high sensitivity -> Require Admin Token
|
||||
allowed, error = require_admin_token(request)
|
||||
if not allowed:
|
||||
return web.json_response({"ok": False, "error": error}, status=403)
|
||||
|
||||
prompt_id = request.match_info.get("prompt_id")
|
||||
if not prompt_id:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "missing_prompt_id"}, status=400
|
||||
)
|
||||
|
||||
rec = trace_store.get(prompt_id)
|
||||
if not rec:
|
||||
return web.json_response({"ok": False, "error": "not_found"}, status=404)
|
||||
|
||||
# S24: Apply redaction to trace data
|
||||
trace_data = rec.to_dict()
|
||||
try:
|
||||
from ..services.reasoning_redaction import (
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
sanitize_operator_payload,
|
||||
)
|
||||
from ..services.redaction import redact_json
|
||||
except ImportError:
|
||||
from services.reasoning_redaction import ( # type: ignore
|
||||
audit_reasoning_reveal,
|
||||
resolve_reasoning_reveal,
|
||||
sanitize_operator_payload,
|
||||
)
|
||||
from services.redaction import redact_json
|
||||
|
||||
if redact_json:
|
||||
trace_data = redact_json(trace_data)
|
||||
reveal = resolve_reasoning_reveal(request, admin_authorized=allowed)
|
||||
audit_reasoning_reveal(request, target="trace.get", decision=reveal)
|
||||
trace_data = sanitize_operator_payload(
|
||||
trace_data, include_reasoning=reveal["allowed"]
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True, "trace": trace_data})
|
||||
# CRITICAL: trace_response performs require_admin_token( before trace access.
|
||||
return await trace_response(request, _handler_dependencies())
|
||||
|
||||
|
||||
assist = None
|
||||
@@ -884,82 +583,33 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
|
||||
and directly to the aiohttp router with and without /api prefix
|
||||
to ensure robustness against loading order (R26/F24).
|
||||
"""
|
||||
# IMPORTANT: handler MUST be callable. If imports fail, handlers remain None.
|
||||
# Registering a None handler crashes ComfyUI at startup (aiohttp assertion).
|
||||
if not callable(handler):
|
||||
print(
|
||||
f"[OpenClaw] Warning: Skipping route {method} {path} because handler is missing (None)."
|
||||
)
|
||||
return
|
||||
# Phase 3 Deprecation wrapper for legacy paths
|
||||
actual_handler = handler
|
||||
if path.startswith("/moltbot"):
|
||||
from functools import wraps
|
||||
|
||||
@wraps(handler)
|
||||
async def _deprecated_handler(request: web.Request) -> web.Response:
|
||||
try:
|
||||
# Assuming `metrics` is available in scope (from module level imports)
|
||||
if metrics:
|
||||
metrics.inc("legacy_api_hits")
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
|
||||
)
|
||||
response = await handler(request)
|
||||
if build_legacy_route_deprecation_headers:
|
||||
headers = build_legacy_route_deprecation_headers(
|
||||
getattr(request, "path", path)
|
||||
)
|
||||
response_headers = getattr(response, "headers", None)
|
||||
if headers and hasattr(response_headers, "update"):
|
||||
response_headers.update(headers)
|
||||
return response
|
||||
|
||||
actual_handler = _deprecated_handler
|
||||
|
||||
# 1. Standard ComfyUI registration
|
||||
if method == "GET":
|
||||
server.routes.get(path)(actual_handler)
|
||||
elif method == "POST":
|
||||
server.routes.post(path)(actual_handler)
|
||||
elif method == "PUT":
|
||||
server.routes.put(path)(actual_handler)
|
||||
elif method == "DELETE":
|
||||
server.routes.delete(path)(actual_handler)
|
||||
|
||||
# 2. Hardened direct registration
|
||||
if hasattr(server, "app") and hasattr(server.app, "router"):
|
||||
# We try to register /api/... and legacy /... explicitly
|
||||
# This fixes 404s if the extension loads after ComfyUI has compiled routes
|
||||
targets = [path, "/api" + path]
|
||||
for t in targets:
|
||||
try:
|
||||
# IMPORTANT: fallback routes must use the same wrapper as PromptServer.
|
||||
# Registering the raw legacy handler bypasses deprecation telemetry/headers.
|
||||
server.app.router.add_route(method, t, actual_handler)
|
||||
except RuntimeError:
|
||||
# Route likely exists (e.g. added by step 1 or duplicate)
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[OpenClaw] Warning: Failed to register fallback route {t}: {e}")
|
||||
|
||||
|
||||
def _is_openclaw_managed_path(path: str) -> bool:
|
||||
if not isinstance(path, str):
|
||||
return False
|
||||
return (
|
||||
path.startswith("/openclaw")
|
||||
or path.startswith("/moltbot")
|
||||
or path.startswith("/api/openclaw")
|
||||
or path.startswith("/api/moltbot")
|
||||
or path.startswith("/bridge")
|
||||
or path.startswith("/api/bridge")
|
||||
orchestrate_dual_route(
|
||||
server,
|
||||
method,
|
||||
path,
|
||||
handler,
|
||||
metrics=metrics,
|
||||
legacy_headers_builder=build_legacy_route_deprecation_headers,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_mae_profile() -> str:
|
||||
try:
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.effective_security_posture import (
|
||||
get_effective_security_posture,
|
||||
)
|
||||
else:
|
||||
from services.effective_security_posture import (
|
||||
get_effective_security_posture,
|
||||
)
|
||||
posture = get_effective_security_posture(required=False)
|
||||
if posture is not None:
|
||||
return str(posture.mae_profile)
|
||||
except ImportError:
|
||||
# IMPORTANT: dependency-light import mode retains the accepted resolver below.
|
||||
pass
|
||||
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local").strip().lower()
|
||||
if profile in {"public", "hardened"}:
|
||||
return profile
|
||||
@@ -972,45 +622,16 @@ def _resolve_mae_profile() -> str:
|
||||
runtime_profile = get_runtime_profile().value
|
||||
if runtime_profile == "hardened":
|
||||
return "hardened"
|
||||
except Exception:
|
||||
except ImportError:
|
||||
# IMPORTANT: optional standalone import absence may fall back to the deployment
|
||||
# profile, but unexpected resolver failures must propagate instead of downgrading
|
||||
# hardened posture silently.
|
||||
pass
|
||||
return profile or "local"
|
||||
|
||||
|
||||
def _run_mae_startup_gate(server) -> None:
|
||||
if not hasattr(server, "app"):
|
||||
return
|
||||
|
||||
try:
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
generate_manifest,
|
||||
validate_mae_posture,
|
||||
)
|
||||
else:
|
||||
from services.endpoint_manifest import (
|
||||
generate_manifest,
|
||||
validate_mae_posture,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[OpenClaw] Warning: S60 MAE gate unavailable: {e}")
|
||||
return
|
||||
|
||||
mae_profile = _resolve_mae_profile()
|
||||
manifest = generate_manifest(server.app)
|
||||
scoped_manifest = [
|
||||
entry for entry in manifest if _is_openclaw_managed_path(entry.get("path", ""))
|
||||
]
|
||||
ok, violations = validate_mae_posture(scoped_manifest, profile=mae_profile)
|
||||
if ok:
|
||||
return
|
||||
|
||||
message = "S60 MAE posture validation failed:\n" + "\n".join(
|
||||
f"- {item}" for item in violations
|
||||
)
|
||||
if mae_profile in {"public", "hardened"}:
|
||||
raise RuntimeError(message)
|
||||
print(f"[OpenClaw] Warning: {message}")
|
||||
run_mae_startup_gate(server, _resolve_mae_profile)
|
||||
|
||||
|
||||
def register_routes(server) -> None:
|
||||
@@ -1022,18 +643,29 @@ def register_routes(server) -> None:
|
||||
# Must run BEFORE any route or worker registration.
|
||||
try:
|
||||
try:
|
||||
from ..services.effective_security_posture import (
|
||||
get_effective_security_posture,
|
||||
resolve_effective_security_posture,
|
||||
)
|
||||
from ..services.startup_profile_gate import enforce_startup_gate
|
||||
except (ImportError, ValueError):
|
||||
from services.effective_security_posture import (
|
||||
get_effective_security_posture,
|
||||
resolve_effective_security_posture,
|
||||
)
|
||||
from services.startup_profile_gate import enforce_startup_gate
|
||||
|
||||
enforce_startup_gate()
|
||||
posture = get_effective_security_posture(required=False)
|
||||
if posture is None:
|
||||
# Compatibility/direct-test invocation is not the process owner.
|
||||
posture = resolve_effective_security_posture()
|
||||
enforce_startup_gate(posture=posture)
|
||||
except RuntimeError:
|
||||
# CRITICAL: fail-closed. Never continue route registration after S56
|
||||
# startup gate failure.
|
||||
raise
|
||||
|
||||
print("[OpenClaw] Registering routes (Shim Alignment R26)...")
|
||||
prefixes = ["/openclaw", "/moltbot"] # new, legacy
|
||||
core_handlers = {
|
||||
"remote_admin_page_handler": remote_admin_page_handler,
|
||||
"health_handler": health_handler,
|
||||
@@ -1087,95 +719,27 @@ def register_routes(server) -> None:
|
||||
"select_apply_winner_handler": select_apply_winner_handler,
|
||||
}
|
||||
|
||||
# Core Observability & Config
|
||||
for prefix in prefixes:
|
||||
register_route_family(
|
||||
server,
|
||||
register_dual_route,
|
||||
build_core_route_specs(prefix, core_handlers),
|
||||
)
|
||||
|
||||
# F8/F21 Assist Routes
|
||||
# R84 Boot Boundary: CORE (Planner/Refiner part of core/assist)
|
||||
if assist:
|
||||
for prefix in prefixes:
|
||||
register_route_family(
|
||||
server,
|
||||
register_dual_route,
|
||||
build_assist_route_specs(prefix, assist),
|
||||
)
|
||||
|
||||
# R126: Connector installation diagnostics/read APIs
|
||||
connector_handlers = None
|
||||
if connector_installations_list_handler:
|
||||
connector_installation_handlers = {
|
||||
connector_handlers = {
|
||||
"connector_installations_list_handler": connector_installations_list_handler,
|
||||
"connector_extraction_contract_handler": connector_extraction_contract_handler,
|
||||
"connector_installation_resolve_handler": connector_installation_resolve_handler,
|
||||
"connector_installation_audit_handler": connector_installation_audit_handler,
|
||||
"connector_installation_get_handler": connector_installation_get_handler,
|
||||
}
|
||||
for prefix in prefixes:
|
||||
register_route_family(
|
||||
server,
|
||||
register_dual_route,
|
||||
build_connector_installation_route_specs(
|
||||
prefix, connector_installation_handlers
|
||||
),
|
||||
)
|
||||
|
||||
# F10 Bridge Routes (Sidecar)
|
||||
# R84 Boot Boundary: BRIDGE
|
||||
# F10 Bridge Routes (Sidecar)
|
||||
# R84 Boot Boundary: BRIDGE
|
||||
try:
|
||||
try:
|
||||
from ..api.bridge import register_bridge_routes
|
||||
from ..services.modules import ModuleCapability, is_module_enabled
|
||||
except (ImportError, ValueError):
|
||||
from api.bridge import register_bridge_routes
|
||||
from services.modules import ModuleCapability, is_module_enabled
|
||||
|
||||
if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE):
|
||||
register_bridge_routes(server.app)
|
||||
print("[OpenClaw] Bridge routes registered")
|
||||
elif not is_module_enabled(ModuleCapability.BRIDGE):
|
||||
print("[OpenClaw] Bridge module disabled; skipping route registration")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_run_mae_startup_gate(server)
|
||||
|
||||
# S8/S23/F11 Asset Packs
|
||||
# R84 Boot Boundary: REGISTRY_SYNC (Packs management)
|
||||
try:
|
||||
try:
|
||||
from ..api.packs import PacksHandlers
|
||||
from ..services.modules import ModuleCapability, is_module_enabled
|
||||
except (ImportError, ValueError):
|
||||
from api.packs import PacksHandlers
|
||||
from services.modules import ModuleCapability, is_module_enabled
|
||||
|
||||
# Packs are currently treated as part of CORE or REGISTRY_SYNC depending on strictness.
|
||||
# For now, we bind them to REGISTRY_SYNC if we want to segment them,
|
||||
# but realistically they are often core local features.
|
||||
# Let's check REGISTRY_SYNC for import/export features specifically if we wanted to split,
|
||||
# but keeping them enabled by default for now unless R84 explicitly segments them.
|
||||
# DESIGN DECISION: Packs are local core features. Registry sync is remote.
|
||||
# We will keep basic pack routes, but R84 might control remote interactions later.
|
||||
|
||||
try:
|
||||
from ..config import DATA_DIR
|
||||
except (ImportError, ValueError):
|
||||
from config import DATA_DIR
|
||||
|
||||
packs = PacksHandlers(DATA_DIR)
|
||||
|
||||
for prefix in prefixes:
|
||||
register_route_family(
|
||||
server,
|
||||
register_dual_route,
|
||||
build_pack_route_specs(prefix, packs),
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
register_route_families(
|
||||
server,
|
||||
RouteRegistrationDependencies(
|
||||
build_core_route_specs=build_core_route_specs,
|
||||
build_assist_route_specs=build_assist_route_specs,
|
||||
build_connector_installation_route_specs=build_connector_installation_route_specs,
|
||||
build_pack_route_specs=build_pack_route_specs,
|
||||
register_route_family=register_route_family,
|
||||
register_dual_route=register_dual_route,
|
||||
core_handlers=core_handlers,
|
||||
assist=assist,
|
||||
connector_installation_handlers=connector_handlers,
|
||||
run_mae_startup_gate=_run_mae_startup_gate,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Owned Feishu card, response, and media-delivery mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
|
||||
|
||||
from ..reply_visibility import decide_reply_visibility
|
||||
from .feishu_installation_manager import FeishuBinding
|
||||
|
||||
# mypy: disable-error-code="attr-defined,no-any-return"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeishuDeliveryTarget:
|
||||
channel_id: str
|
||||
reply_to_message_id: str = ""
|
||||
workspace_id: str = ""
|
||||
account_id: str = ""
|
||||
|
||||
|
||||
class FeishuDeliveryMixin:
|
||||
def _build_card_button_value(
|
||||
self,
|
||||
button: Dict[str, Any],
|
||||
*,
|
||||
target: FeishuDeliveryTarget,
|
||||
binding: FeishuBinding,
|
||||
signing_secret: str,
|
||||
) -> Dict[str, Any]:
|
||||
contract = self._callback_contract_for_binding(
|
||||
binding=binding,
|
||||
signing_secret=signing_secret,
|
||||
)
|
||||
command_text = str(button.get("value", "") or "").strip()
|
||||
callback_payload = {
|
||||
"label": str(button.get("label", "") or "").strip(),
|
||||
"command": command_text,
|
||||
"approval_id": str(button.get("approval_id", "") or "").strip(),
|
||||
"workspace_id": target.workspace_id or binding.workspace_id,
|
||||
"account_id": target.account_id or binding.account_id,
|
||||
"channel_id": target.channel_id,
|
||||
"message_id": target.reply_to_message_id,
|
||||
}
|
||||
envelope = contract.build_envelope(
|
||||
request_id=secrets.token_hex(12),
|
||||
workspace_id=callback_payload["workspace_id"],
|
||||
action_type=self._adapter_infer_callback_action_type(command_text, button),
|
||||
payload=callback_payload,
|
||||
)
|
||||
return {
|
||||
"callback_envelope": dict(envelope.__dict__),
|
||||
"payload": callback_payload,
|
||||
}
|
||||
|
||||
def _build_interactive_card(
|
||||
self,
|
||||
target: FeishuDeliveryTarget,
|
||||
text: str,
|
||||
buttons: list[dict],
|
||||
*,
|
||||
binding: FeishuBinding,
|
||||
secrets: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
signing_secret = str(
|
||||
secrets.get("app_secret", "") or binding.app_secret or ""
|
||||
).strip()
|
||||
if not signing_secret:
|
||||
raise RuntimeError("feishu_callback_signing_secret_missing")
|
||||
actions = []
|
||||
for button in buttons[:6]:
|
||||
command_text = str(button.get("value", "") or "").strip()
|
||||
if not command_text:
|
||||
continue
|
||||
actions.append(
|
||||
{
|
||||
"tag": "button",
|
||||
"type": str(button.get("style", "") or "default"),
|
||||
"text": {
|
||||
"tag": "plain_text",
|
||||
"content": str(button.get("label", "") or "OpenClaw"),
|
||||
},
|
||||
"value": self._build_card_button_value(
|
||||
button,
|
||||
target=target,
|
||||
binding=binding,
|
||||
signing_secret=signing_secret,
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"tag": "plain_text", "content": "OpenClaw"},
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": text or "OpenClaw"},
|
||||
{"tag": "action", "actions": actions},
|
||||
],
|
||||
}
|
||||
|
||||
async def _send_interactive_reply(
|
||||
self,
|
||||
target: FeishuDeliveryTarget,
|
||||
text: str,
|
||||
buttons: list[dict],
|
||||
) -> None:
|
||||
resolution, binding, secrets = self._resolve_delivery_binding(
|
||||
workspace_id=target.workspace_id,
|
||||
account_id=target.account_id,
|
||||
)
|
||||
if binding is None or not resolution.ok:
|
||||
self._adapter_logger().warning(
|
||||
"Feishu interactive reply dropped: no workspace binding available (%s / %s)",
|
||||
target.workspace_id or "no-workspace",
|
||||
target.account_id or "no-account",
|
||||
)
|
||||
return
|
||||
token = await self._get_tenant_access_token(
|
||||
binding=binding,
|
||||
workspace_id=target.workspace_id,
|
||||
account_id=target.account_id,
|
||||
)
|
||||
api_base = self._adapter_resolve_domain_base(binding.domain)
|
||||
card = self._build_interactive_card(
|
||||
target,
|
||||
text,
|
||||
buttons,
|
||||
binding=binding,
|
||||
secrets=secrets,
|
||||
)
|
||||
payload = {
|
||||
"content": json.dumps(card, ensure_ascii=False),
|
||||
"msg_type": "interactive",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
if target.reply_to_message_id:
|
||||
url = (
|
||||
f"{api_base}/open-apis/im/v1/messages/"
|
||||
f"{target.reply_to_message_id}/reply"
|
||||
)
|
||||
else:
|
||||
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
|
||||
payload["receive_id"] = target.channel_id
|
||||
try:
|
||||
data = self._adapter_safe_request_json(
|
||||
method="POST",
|
||||
url=url,
|
||||
json_body=payload,
|
||||
headers=headers,
|
||||
content_type="application/json; charset=utf-8",
|
||||
timeout_sec=15,
|
||||
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=exc.reason,
|
||||
status_code=exc.status_code,
|
||||
details={"phase": "interactive_reply"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu interactive reply failed: status=%s", exc.status_code
|
||||
)
|
||||
return
|
||||
if data.get("code", 0) != 0:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=str(data.get("msg", "unknown") or "unknown"),
|
||||
status_code=200,
|
||||
details={"phase": "interactive_reply"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu interactive reply failed: %s", data.get("msg", "unknown")
|
||||
)
|
||||
|
||||
async def _send_reply(
|
||||
self,
|
||||
target: FeishuDeliveryTarget,
|
||||
text: str,
|
||||
*,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
ctx = dict(delivery_context or {})
|
||||
if target.workspace_id:
|
||||
ctx.setdefault("workspace_id", target.workspace_id)
|
||||
if target.account_id:
|
||||
ctx.setdefault("account_id", target.account_id)
|
||||
if target.reply_to_message_id:
|
||||
ctx.setdefault("thread_id", target.reply_to_message_id)
|
||||
decision = decide_reply_visibility(
|
||||
delivery_context=ctx,
|
||||
platform="feishu",
|
||||
channel_kind=str(ctx.get("chat_type", "") or ""),
|
||||
in_thread=bool(target.reply_to_message_id),
|
||||
text=text,
|
||||
)
|
||||
if decision.suppressed:
|
||||
self._adapter_logger().info(
|
||||
"Suppressed Feishu reply channel=%s reason=%s",
|
||||
target.channel_id,
|
||||
decision.reason,
|
||||
)
|
||||
return
|
||||
resolution, binding, _ = self._resolve_delivery_binding(
|
||||
workspace_id=target.workspace_id,
|
||||
account_id=target.account_id,
|
||||
)
|
||||
if binding is None or not resolution.ok:
|
||||
self._adapter_logger().warning(
|
||||
"Feishu reply dropped: no workspace binding available (%s / %s)",
|
||||
target.workspace_id or "no-workspace",
|
||||
target.account_id or "no-account",
|
||||
)
|
||||
return
|
||||
token = await self._get_tenant_access_token(
|
||||
binding=binding,
|
||||
workspace_id=target.workspace_id,
|
||||
account_id=target.account_id,
|
||||
)
|
||||
api_base = self._adapter_resolve_domain_base(binding.domain)
|
||||
payload = {
|
||||
"content": json.dumps({"text": text}, ensure_ascii=False),
|
||||
"msg_type": "text",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
if target.reply_to_message_id:
|
||||
url = (
|
||||
f"{api_base}/open-apis/im/v1/messages/"
|
||||
f"{target.reply_to_message_id}/reply"
|
||||
)
|
||||
else:
|
||||
url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
|
||||
payload["receive_id"] = target.channel_id
|
||||
try:
|
||||
data = self._adapter_safe_request_json(
|
||||
method="POST",
|
||||
url=url,
|
||||
json_body=payload,
|
||||
headers=headers,
|
||||
content_type="application/json; charset=utf-8",
|
||||
timeout_sec=15,
|
||||
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=exc.reason,
|
||||
status_code=exc.status_code,
|
||||
details={"phase": "reply"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu reply failed: status=%s", exc.status_code
|
||||
)
|
||||
return
|
||||
if data.get("code", 0) != 0:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=str(data.get("msg", "unknown") or "unknown"),
|
||||
status_code=200,
|
||||
details={"phase": "reply"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu reply failed: %s",
|
||||
data.get("msg", "unknown"),
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
ctx = dict(delivery_context or {})
|
||||
await self._send_reply(
|
||||
self._adapter_delivery_target(
|
||||
channel_id=channel_id,
|
||||
reply_to_message_id=str(ctx.get("thread_id", "") or "").strip(),
|
||||
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
|
||||
account_id=str(ctx.get("account_id", "") or "").strip(),
|
||||
),
|
||||
text,
|
||||
delivery_context=ctx,
|
||||
)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
channel_id: str,
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
ctx = dict(delivery_context or {})
|
||||
resolution, binding, _ = self._resolve_delivery_binding(
|
||||
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
|
||||
account_id=str(ctx.get("account_id", "") or "").strip(),
|
||||
)
|
||||
if binding is None or not resolution.ok:
|
||||
self._adapter_logger().warning(
|
||||
"Feishu image dropped: no workspace binding available (%s / %s)",
|
||||
str(ctx.get("workspace_id", "") or "").strip() or "no-workspace",
|
||||
str(ctx.get("account_id", "") or "").strip() or "no-account",
|
||||
)
|
||||
return
|
||||
token = await self._get_tenant_access_token(
|
||||
binding=binding,
|
||||
workspace_id=str(ctx.get("workspace_id", "") or "").strip(),
|
||||
account_id=str(ctx.get("account_id", "") or "").strip(),
|
||||
)
|
||||
api_base = self._adapter_resolve_domain_base(binding.domain)
|
||||
upload_headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
upload_body, upload_content_type = self._adapter_build_multipart_form(
|
||||
fields={"image_type": "message"},
|
||||
file_field="image",
|
||||
filename=filename,
|
||||
file_bytes=image_data,
|
||||
file_content_type="image/png",
|
||||
)
|
||||
try:
|
||||
upload_payload = self._adapter_safe_request_json(
|
||||
method="POST",
|
||||
url=f"{api_base}/open-apis/im/v1/images",
|
||||
raw_body=upload_body,
|
||||
headers=upload_headers,
|
||||
content_type=upload_content_type,
|
||||
timeout_sec=30,
|
||||
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=exc.reason,
|
||||
status_code=exc.status_code,
|
||||
details={"phase": "image_upload"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu image upload failed: status=%s", exc.status_code
|
||||
)
|
||||
return
|
||||
image_key = str(
|
||||
(upload_payload.get("data") or {}).get("image_key", "") or ""
|
||||
).strip()
|
||||
if upload_payload.get("code", 0) != 0 or not image_key:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=str(upload_payload.get("msg", "unknown") or "unknown"),
|
||||
status_code=200,
|
||||
details={"phase": "image_upload"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu image upload failed: %s",
|
||||
upload_payload.get("msg", "unknown"),
|
||||
)
|
||||
return
|
||||
message_payload = {
|
||||
"content": json.dumps({"image_key": image_key}, ensure_ascii=False),
|
||||
"msg_type": "image",
|
||||
}
|
||||
thread_id = str(ctx.get("thread_id", "") or "").strip()
|
||||
if thread_id:
|
||||
send_url = f"{api_base}/open-apis/im/v1/messages/{thread_id}/reply"
|
||||
else:
|
||||
send_url = f"{api_base}/open-apis/im/v1/messages?receive_id_type=chat_id"
|
||||
message_payload["receive_id"] = channel_id
|
||||
try:
|
||||
self._adapter_safe_request_json(
|
||||
method="POST",
|
||||
url=send_url,
|
||||
json_body=message_payload,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
content_type="application/json; charset=utf-8",
|
||||
timeout_sec=30,
|
||||
allow_hosts=self._adapter_allowed_api_hosts(binding.domain),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=exc.reason,
|
||||
status_code=exc.status_code,
|
||||
details={"phase": "image_send"},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Feishu image send failed: status=%s", exc.status_code
|
||||
)
|
||||
if caption:
|
||||
await self.send_message(
|
||||
channel_id,
|
||||
caption,
|
||||
delivery_context=ctx,
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Owned Feishu webhook ingress and callback transaction mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from services.connector_callback_contract import (
|
||||
CallbackActorContext,
|
||||
CallbackDecisionCode,
|
||||
ConnectorCallbackContract,
|
||||
)
|
||||
|
||||
from ..contract import CommandRequest
|
||||
from .feishu_installation_manager import FeishuBinding
|
||||
|
||||
# mypy: disable-error-code="attr-defined,index,no-any-return"
|
||||
|
||||
|
||||
class FeishuIngressMixin:
|
||||
async def handle_event(self, request):
|
||||
_, web = self._adapter_import_aiohttp_web()
|
||||
try:
|
||||
body = await request.read()
|
||||
except Exception:
|
||||
return self._adapter_make_response(web, status=400, text="Bad request")
|
||||
if len(body) > self._adapter_max_body_bytes():
|
||||
return self._adapter_make_response(
|
||||
web, status=413, text="Payload too large"
|
||||
)
|
||||
try:
|
||||
payload = json.loads(body or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return self._adapter_make_response(web, status=400, text="Bad JSON")
|
||||
if self._is_challenge(payload):
|
||||
if not self._verify_request_token(payload):
|
||||
return self._adapter_make_response(
|
||||
web, status=401, text="Invalid verification token"
|
||||
)
|
||||
return self._adapter_make_json_response(
|
||||
web, {"challenge": str(payload.get("challenge", "") or "")}
|
||||
)
|
||||
if not self._verify_request_token(payload):
|
||||
return self._adapter_make_response(
|
||||
web, status=401, text="Invalid verification token"
|
||||
)
|
||||
try:
|
||||
await self.process_event_payload(payload)
|
||||
except ValueError as exc:
|
||||
safe_code = self._adapter_safe_external_error_code("event_rejected", exc)
|
||||
self._adapter_logger().warning("Feishu event rejected: %s", safe_code)
|
||||
return self._adapter_make_response(
|
||||
web,
|
||||
status=400,
|
||||
text=safe_code,
|
||||
)
|
||||
return self._adapter_make_response(web, status=200, text="OK")
|
||||
|
||||
async def handle_callback(self, request):
|
||||
_, web = self._adapter_import_aiohttp_web()
|
||||
try:
|
||||
body = await request.read()
|
||||
except Exception:
|
||||
return self._adapter_make_response(web, status=400, text="Bad request")
|
||||
if len(body) > self._adapter_max_body_bytes():
|
||||
return self._adapter_make_response(
|
||||
web, status=413, text="Payload too large"
|
||||
)
|
||||
try:
|
||||
payload = json.loads(body or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return self._adapter_make_response(web, status=400, text="Bad JSON")
|
||||
try:
|
||||
response = await self.process_callback_payload(payload)
|
||||
except ValueError as exc:
|
||||
safe_code = self._adapter_safe_external_error_code("callback_rejected", exc)
|
||||
self._adapter_logger().warning("Feishu callback rejected: %s", safe_code)
|
||||
return self._adapter_make_json_response(
|
||||
web,
|
||||
{
|
||||
"ok": False,
|
||||
"error": safe_code,
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
return self._adapter_make_json_response(web, response)
|
||||
|
||||
def _is_challenge(self, payload: Dict[str, Any]) -> bool:
|
||||
return bool(
|
||||
payload.get("challenge")
|
||||
and str(payload.get("type", "") or "").strip().lower() == "url_verification"
|
||||
)
|
||||
|
||||
def _verify_request_token(self, payload: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
self._resolve_inbound_binding(payload)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _extract_callback_action(self, payload: Dict[str, Any]) -> Tuple[
|
||||
Dict[str, Any],
|
||||
Dict[str, Any],
|
||||
Dict[str, Any],
|
||||
Dict[str, Any],
|
||||
str,
|
||||
str,
|
||||
]:
|
||||
header = payload.get("header") or {}
|
||||
event = payload.get("event") or {}
|
||||
action = payload.get("action") or event.get("action") or {}
|
||||
if not action and isinstance(event.get("actions"), list):
|
||||
first_action = event.get("actions")[0] if event.get("actions") else {}
|
||||
if isinstance(first_action, dict):
|
||||
action = first_action
|
||||
if not isinstance(action, dict):
|
||||
raise ValueError("invalid_callback_action")
|
||||
raw_value = action.get("value") or {}
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = self._adapter_json_loads_safe(raw_value)
|
||||
if not isinstance(raw_value, dict):
|
||||
raise ValueError("invalid_callback_value")
|
||||
envelope = raw_value.get("callback_envelope") or {}
|
||||
callback_payload = raw_value.get("payload") or {}
|
||||
if not isinstance(envelope, dict) or not isinstance(callback_payload, dict):
|
||||
raise ValueError("invalid_callback_envelope")
|
||||
workspace_id = str(
|
||||
header.get("tenant_key")
|
||||
or event.get("tenant_key")
|
||||
or callback_payload.get("workspace_id")
|
||||
or ""
|
||||
).strip()
|
||||
account_id = str(callback_payload.get("account_id", "") or "").strip()
|
||||
return header, event, envelope, callback_payload, workspace_id, account_id
|
||||
|
||||
def _callback_contract_for_binding(
|
||||
self,
|
||||
*,
|
||||
binding: FeishuBinding,
|
||||
signing_secret: str,
|
||||
) -> ConnectorCallbackContract:
|
||||
cache_key = self._cache_key_for_binding(binding)
|
||||
if (
|
||||
self._callback_contracts.get(cache_key) is not None
|
||||
and self._callback_contract_secrets.get(cache_key) == signing_secret
|
||||
):
|
||||
return self._callback_contracts[cache_key]
|
||||
contract = ConnectorCallbackContract(
|
||||
signing_secret=signing_secret,
|
||||
installation_registry=self._installation_manager.registry,
|
||||
action_policy_map=self._adapter_callback_policy_map(),
|
||||
)
|
||||
self._callback_contracts[cache_key] = contract
|
||||
self._callback_contract_secrets[cache_key] = signing_secret
|
||||
return contract
|
||||
|
||||
def _actor_context_for_callback(
|
||||
self,
|
||||
*,
|
||||
actor_id: str,
|
||||
actor_open_id: str,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
workspace_id: str,
|
||||
account_id: str,
|
||||
command_text: str,
|
||||
) -> Tuple[CallbackActorContext, CommandRequest]:
|
||||
request = CommandRequest(
|
||||
platform="feishu",
|
||||
sender_id=actor_id or actor_open_id,
|
||||
channel_id=channel_id or actor_id or actor_open_id,
|
||||
username=actor_id or actor_open_id,
|
||||
message_id=message_id or f"cb-{secrets.token_hex(4)}",
|
||||
text=command_text,
|
||||
timestamp=time.time(),
|
||||
workspace_id=workspace_id,
|
||||
thread_id=message_id,
|
||||
metadata={
|
||||
"account_id": account_id,
|
||||
"sender_open_id": actor_open_id,
|
||||
"interactive_callback": True,
|
||||
},
|
||||
)
|
||||
actor = CallbackActorContext(
|
||||
is_admin=self.router._is_admin(request.sender_id),
|
||||
is_trusted=self.router._is_trusted(request),
|
||||
user_id=request.sender_id,
|
||||
tenant_id=workspace_id or request.workspace_id or "",
|
||||
)
|
||||
return actor, request
|
||||
|
||||
def _build_callback_response(
|
||||
self,
|
||||
*,
|
||||
ok: bool,
|
||||
text: str,
|
||||
response_type: str = "info",
|
||||
card: Optional[Dict[str, Any]] = None,
|
||||
duplicate: bool = False,
|
||||
decision_code: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
response = {
|
||||
"ok": ok,
|
||||
"duplicate": duplicate,
|
||||
"decision_code": decision_code,
|
||||
"toast": {
|
||||
"type": response_type,
|
||||
"content": text[:500] if text else "",
|
||||
},
|
||||
}
|
||||
if card is not None:
|
||||
response["card"] = card
|
||||
return response
|
||||
|
||||
def _build_request(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
binding: FeishuBinding,
|
||||
bot_open_id: str,
|
||||
) -> Optional[CommandRequest]:
|
||||
header = payload.get("header") or {}
|
||||
if (
|
||||
str(header.get("event_type", "") or "").strip()
|
||||
not in self._adapter_supported_event_types()
|
||||
):
|
||||
return None
|
||||
event = payload.get("event") or {}
|
||||
message = event.get("message") or {}
|
||||
sender = event.get("sender") or {}
|
||||
sender_id = sender.get("sender_id") or {}
|
||||
mentions = self._adapter_normalize_mentions(message)
|
||||
sender_user_id = str(sender_id.get("user_id", "") or "").strip()
|
||||
sender_open_id = str(sender_id.get("open_id", "") or "").strip()
|
||||
chat_id = str(message.get("chat_id", "") or "").strip()
|
||||
chat_type = str(message.get("chat_type", "") or "").strip().lower()
|
||||
message_id = str(message.get("message_id", "") or "").strip()
|
||||
workspace_id = (
|
||||
str(header.get("tenant_key", "") or "").strip() or binding.workspace_id
|
||||
)
|
||||
if not sender_user_id and not sender_open_id:
|
||||
return None
|
||||
if not chat_id or not message_id:
|
||||
return None
|
||||
if sender_open_id and bot_open_id and sender_open_id == bot_open_id:
|
||||
return None
|
||||
raw_text = self._adapter_parse_message_text(message)
|
||||
if not raw_text:
|
||||
return None
|
||||
mentioned_bot = False
|
||||
if bot_open_id:
|
||||
for mention in mentions:
|
||||
open_id = str(((mention.get("id") or {}).get("open_id")) or "").strip()
|
||||
if open_id and open_id == bot_open_id:
|
||||
mentioned_bot = True
|
||||
break
|
||||
text = self._adapter_strip_bot_mention(raw_text, mentions, bot_open_id)
|
||||
if (
|
||||
chat_type == "group"
|
||||
and self.config.feishu_require_mention
|
||||
and not mentioned_bot
|
||||
):
|
||||
return None
|
||||
effective_sender = sender_user_id or sender_open_id
|
||||
return CommandRequest(
|
||||
platform="feishu",
|
||||
sender_id=effective_sender,
|
||||
channel_id=chat_id,
|
||||
username=effective_sender,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
timestamp=time.time(),
|
||||
workspace_id=workspace_id,
|
||||
thread_id=(
|
||||
str(message.get("root_id", "") or "").strip()
|
||||
or (message_id if self.config.feishu_reply_in_thread else "")
|
||||
),
|
||||
metadata={
|
||||
"account_id": binding.account_id,
|
||||
"chat_type": chat_type,
|
||||
"mentioned_bot": mentioned_bot,
|
||||
"message_type": str(message.get("message_type", "") or "").strip(),
|
||||
"sender_open_id": sender_open_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def process_event_payload(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
binding: Optional[FeishuBinding] = None,
|
||||
) -> None:
|
||||
header = payload.get("header") or {}
|
||||
event_id = str(header.get("event_id", "") or "").strip()
|
||||
if not event_id:
|
||||
raise ValueError("Missing event_id")
|
||||
if not self._replay_guard.check_and_record(event_id):
|
||||
return
|
||||
effective_binding = binding or self._resolve_inbound_binding(payload)
|
||||
bot_open_id = self._cached_bot_open_id(effective_binding)
|
||||
message = (payload.get("event") or {}).get("message") or {}
|
||||
chat_type = str(message.get("chat_type", "") or "").strip().lower()
|
||||
if not bot_open_id and chat_type == "group":
|
||||
bot_open_id = await self._fetch_bot_open_id(
|
||||
binding=effective_binding, allow_degrade=True
|
||||
)
|
||||
request = self._build_request(
|
||||
payload,
|
||||
binding=effective_binding,
|
||||
bot_open_id=bot_open_id,
|
||||
)
|
||||
if request is None:
|
||||
return
|
||||
if self._user_allowlist.entries:
|
||||
user_result = self._user_allowlist.evaluate(str(request.sender_id))
|
||||
if user_result.decision == "deny":
|
||||
return
|
||||
if self._chat_allowlist.entries:
|
||||
chat_result = self._chat_allowlist.evaluate(str(request.channel_id))
|
||||
if chat_result.decision == "deny":
|
||||
return
|
||||
response = await self.router.handle(request)
|
||||
resp_text = str(getattr(response, "text", "") or "").strip()
|
||||
buttons = getattr(response, "buttons", []) or []
|
||||
target = self._adapter_delivery_target(
|
||||
channel_id=request.channel_id,
|
||||
reply_to_message_id=request.thread_id,
|
||||
workspace_id=request.workspace_id,
|
||||
account_id=str(request.metadata.get("account_id", "") or ""),
|
||||
)
|
||||
if buttons:
|
||||
await self._send_interactive_reply(target, resp_text, buttons)
|
||||
elif resp_text:
|
||||
await self._send_reply(
|
||||
target,
|
||||
resp_text,
|
||||
delivery_context={
|
||||
"workspace_id": request.workspace_id,
|
||||
"thread_id": request.thread_id,
|
||||
"account_id": str(request.metadata.get("account_id", "") or ""),
|
||||
"chat_type": str(request.metadata.get("chat_type", "") or ""),
|
||||
"mentioned_bot": bool(request.metadata.get("mentioned_bot")),
|
||||
},
|
||||
)
|
||||
|
||||
async def process_callback_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_, _, envelope_dict, callback_payload, workspace_id, account_id = (
|
||||
self._extract_callback_action(payload)
|
||||
)
|
||||
resolution, binding, secrets = self._resolve_delivery_binding(
|
||||
workspace_id=workspace_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
if binding is None or not resolution.ok:
|
||||
raise ValueError(resolution.reject_reason or "missing_binding")
|
||||
signing_secret = str(
|
||||
secrets.get("app_secret", "") or binding.app_secret or ""
|
||||
).strip()
|
||||
if not signing_secret:
|
||||
raise ValueError("missing_callback_signing_secret")
|
||||
contract = self._callback_contract_for_binding(
|
||||
binding=binding,
|
||||
signing_secret=signing_secret,
|
||||
)
|
||||
event = payload.get("event") or {}
|
||||
operator = payload.get("operator") or event.get("operator") or {}
|
||||
operator_id = operator.get("operator_id") or operator.get("sender_id") or {}
|
||||
actor_id = str(
|
||||
operator.get("user_id")
|
||||
or operator_id.get("user_id")
|
||||
or callback_payload.get("actor_user_id")
|
||||
or ""
|
||||
).strip()
|
||||
actor_open_id = str(
|
||||
operator.get("open_id")
|
||||
or operator_id.get("open_id")
|
||||
or callback_payload.get("actor_open_id")
|
||||
or ""
|
||||
).strip()
|
||||
command_text = str(callback_payload.get("command", "") or "").strip()
|
||||
actor, request = self._actor_context_for_callback(
|
||||
actor_id=actor_id,
|
||||
actor_open_id=actor_open_id,
|
||||
channel_id=str(
|
||||
payload.get("open_chat_id")
|
||||
or event.get("open_chat_id")
|
||||
or callback_payload.get("channel_id")
|
||||
or ""
|
||||
).strip(),
|
||||
message_id=str(
|
||||
payload.get("open_message_id")
|
||||
or event.get("open_message_id")
|
||||
or callback_payload.get("message_id")
|
||||
or ""
|
||||
).strip(),
|
||||
workspace_id=workspace_id or binding.workspace_id,
|
||||
account_id=binding.account_id,
|
||||
command_text=command_text,
|
||||
)
|
||||
decision = contract.evaluate(
|
||||
platform="feishu",
|
||||
envelope_dict=envelope_dict,
|
||||
payload=callback_payload,
|
||||
actor=actor,
|
||||
)
|
||||
if decision.decision_code == CallbackDecisionCode.REJECT_REPLAY.value:
|
||||
return self._build_callback_response(
|
||||
ok=True,
|
||||
text="Action already processed.",
|
||||
response_type="info",
|
||||
duplicate=True,
|
||||
decision_code=decision.decision_code,
|
||||
)
|
||||
if not decision.ok and not decision.requires_approval:
|
||||
raise ValueError(decision.message or decision.decision_code)
|
||||
request.text = (
|
||||
self._adapter_force_approval_command(request.text)
|
||||
if decision.requires_approval
|
||||
else request.text
|
||||
)
|
||||
request_id = str(envelope_dict.get("request_id", "") or "")
|
||||
contract.acknowledge_request(request_id)
|
||||
try:
|
||||
response = await self.router.handle(request)
|
||||
except Exception:
|
||||
# IMPORTANT: failures before route completion remain retryable.
|
||||
# After router.handle returns, the action may already have side effects,
|
||||
# so completion failures must not release the claim for rerouting.
|
||||
contract.release_request_retryable(
|
||||
request_id, reason="feishu_callback_failed_before_commit"
|
||||
)
|
||||
raise
|
||||
contract.complete_request(request_id)
|
||||
response_text = str(getattr(response, "text", "") or "").strip() or (
|
||||
"Action processed."
|
||||
)
|
||||
response_buttons = getattr(response, "buttons", []) or []
|
||||
card = None
|
||||
if response_buttons:
|
||||
card = self._build_interactive_card(
|
||||
self._adapter_delivery_target(
|
||||
channel_id=request.channel_id,
|
||||
reply_to_message_id=request.thread_id,
|
||||
workspace_id=request.workspace_id,
|
||||
account_id=binding.account_id,
|
||||
),
|
||||
response_text,
|
||||
response_buttons,
|
||||
binding=binding,
|
||||
secrets=secrets,
|
||||
)
|
||||
return self._build_callback_response(
|
||||
ok=True,
|
||||
text=response_text,
|
||||
response_type="success",
|
||||
card=card,
|
||||
decision_code=decision.decision_code,
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Owned Feishu installation, tenant-token, and bot-identity mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from services.connector_installation_registry import InstallationResolution
|
||||
from services.safe_io import STANDARD_OUTBOUND_POLICY, SafeIOHTTPError
|
||||
|
||||
from .feishu_installation_manager import FeishuBinding
|
||||
|
||||
# mypy: disable-error-code="attr-defined,no-any-return"
|
||||
|
||||
|
||||
class FeishuInstallationMixin:
|
||||
def _resolve_inbound_binding(self, payload: Dict[str, Any]) -> FeishuBinding:
|
||||
header = payload.get("header") or {}
|
||||
verification_token = (
|
||||
str(payload.get("token", "") or "").strip()
|
||||
or str(header.get("token", "") or "").strip()
|
||||
or str(((payload.get("event") or {}).get("token")) or "").strip()
|
||||
)
|
||||
workspace_id = str(header.get("tenant_key", "") or "").strip()
|
||||
return self._installation_manager.resolve_inbound_binding(
|
||||
verification_token=verification_token,
|
||||
workspace_id=workspace_id,
|
||||
account_id=self._bound_account_id,
|
||||
)
|
||||
|
||||
def _cache_key_for_binding(self, binding: FeishuBinding) -> str:
|
||||
return binding.installation_id or binding.account_id
|
||||
|
||||
def _cached_bot_open_id(self, binding: FeishuBinding) -> str:
|
||||
return (
|
||||
self._bot_open_ids.get(self._cache_key_for_binding(binding), "")
|
||||
or self._bot_open_id
|
||||
)
|
||||
|
||||
def _resolve_delivery_binding(
|
||||
self, *, workspace_id: str = "", account_id: str = ""
|
||||
) -> Tuple[InstallationResolution, Optional[FeishuBinding], Dict[str, str]]:
|
||||
return self._installation_manager.resolve_binding(
|
||||
workspace_id=workspace_id,
|
||||
account_id=account_id or self._bound_account_id,
|
||||
)
|
||||
|
||||
async def _get_tenant_access_token(
|
||||
self,
|
||||
*,
|
||||
binding: Optional[FeishuBinding] = None,
|
||||
workspace_id: str = "",
|
||||
account_id: str = "",
|
||||
) -> str:
|
||||
resolution, effective_binding, secrets = self._resolve_delivery_binding(
|
||||
workspace_id=workspace_id,
|
||||
account_id=account_id or (binding.account_id if binding else ""),
|
||||
)
|
||||
if effective_binding is None or not resolution.ok:
|
||||
raise RuntimeError(
|
||||
f"feishu_binding_resolution_failed:{resolution.reject_reason or 'missing_binding'}"
|
||||
)
|
||||
cache_key = self._cache_key_for_binding(effective_binding)
|
||||
if self._tenant_access_tokens.get(
|
||||
cache_key
|
||||
) and self._tenant_access_token_expires_at.get(cache_key, 0.0) > (
|
||||
time.time() + 30
|
||||
):
|
||||
return self._tenant_access_tokens[cache_key]
|
||||
app_secret = str(
|
||||
secrets.get("app_secret", "") or effective_binding.app_secret
|
||||
).strip()
|
||||
payload = {
|
||||
"app_id": effective_binding.app_id,
|
||||
"app_secret": app_secret,
|
||||
}
|
||||
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/auth/v3/tenant_access_token/internal"
|
||||
try:
|
||||
data = self._adapter_safe_request_json(
|
||||
method="POST",
|
||||
url=url,
|
||||
json_body=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
content_type="application/json; charset=utf-8",
|
||||
timeout_sec=15,
|
||||
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=exc.reason,
|
||||
status_code=exc.status_code,
|
||||
details={"phase": "tenant_access_token"},
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"feishu_token_fetch_failed:{exc.status_code}:{exc.reason}"
|
||||
) from exc
|
||||
if data.get("code", 0) != 0:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=str(data.get("msg", "unknown") or "unknown"),
|
||||
status_code=200,
|
||||
details={"phase": "tenant_access_token"},
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"feishu_token_fetch_failed:200:{data.get('msg', 'unknown')}"
|
||||
)
|
||||
token = str(data.get("tenant_access_token", "") or "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("feishu_token_fetch_failed:missing_token")
|
||||
expire = int(
|
||||
data.get("expire", self._adapter_token_ttl_sec())
|
||||
or self._adapter_token_ttl_sec()
|
||||
)
|
||||
self._tenant_access_tokens[cache_key] = token
|
||||
self._tenant_access_token_expires_at[cache_key] = time.time() + max(60, expire)
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_resolution_success(
|
||||
resolution.installation.installation_id,
|
||||
effective_binding.workspace_id,
|
||||
)
|
||||
return token
|
||||
|
||||
async def _fetch_bot_open_id(
|
||||
self,
|
||||
*,
|
||||
binding: Optional[FeishuBinding] = None,
|
||||
workspace_id: str = "",
|
||||
account_id: str = "",
|
||||
allow_degrade: bool = False,
|
||||
) -> str:
|
||||
resolution, effective_binding, _ = self._resolve_delivery_binding(
|
||||
workspace_id=workspace_id,
|
||||
account_id=account_id or (binding.account_id if binding else ""),
|
||||
)
|
||||
if effective_binding is None or not resolution.ok:
|
||||
return ""
|
||||
cache_key = self._cache_key_for_binding(effective_binding)
|
||||
if self._bot_open_ids.get(cache_key):
|
||||
return self._bot_open_ids[cache_key]
|
||||
token = await self._get_tenant_access_token(binding=effective_binding)
|
||||
url = f"{self._adapter_resolve_domain_base(effective_binding.domain)}/open-apis/bot/v3/info"
|
||||
try:
|
||||
data = self._adapter_safe_request_json(
|
||||
method="GET",
|
||||
url=url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
timeout_sec=15,
|
||||
allow_hosts=self._adapter_allowed_api_hosts(effective_binding.domain),
|
||||
policy=STANDARD_OUTBOUND_POLICY,
|
||||
)
|
||||
except SafeIOHTTPError as exc:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=exc.reason,
|
||||
status_code=exc.status_code,
|
||||
details={"phase": "bot_info"},
|
||||
)
|
||||
if allow_degrade:
|
||||
return ""
|
||||
return ""
|
||||
if data.get("code", 0) != 0:
|
||||
if resolution.installation is not None:
|
||||
self._installation_manager.mark_api_error(
|
||||
resolution.installation.installation_id,
|
||||
error_code=str(data.get("msg", "unknown") or "unknown"),
|
||||
status_code=200,
|
||||
details={"phase": "bot_info"},
|
||||
)
|
||||
return ""
|
||||
bot_open_id = str(
|
||||
(((data.get("data") or {}).get("bot") or {}).get("open_id")) or ""
|
||||
).strip()
|
||||
if bot_open_id:
|
||||
self._bot_open_ids[cache_key] = bot_open_id
|
||||
self._bot_open_id = bot_open_id
|
||||
return bot_open_id
|
||||
|
||||
async def prime_bot_identity(self) -> None:
|
||||
try:
|
||||
await self._fetch_bot_open_id(
|
||||
account_id=self._bound_account_id
|
||||
or str(self.config.feishu_account_id or "").strip()
|
||||
or str(self.config.feishu_default_account_id or "").strip(),
|
||||
workspace_id=str(self.config.feishu_workspace_id or "").strip(),
|
||||
allow_degrade=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._adapter_logger().debug("Feishu bot identity fetch failed: %s", exc)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
||||
"""Owned Slack response and media-delivery mixin."""
|
||||
|
||||
# ruff: noqa: SIM117, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..reply_visibility import decide_reply_visibility
|
||||
|
||||
# mypy: disable-error-code="attr-defined,no-any-return"
|
||||
|
||||
|
||||
class SlackDeliveryMixin:
|
||||
async def _send_interactive_reply(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
buttons: list[dict],
|
||||
thread_ts: str = "",
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Send a Slack Block Kit message with bounded button actions."""
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
self._adapter_logger().warning(
|
||||
"aiohttp not available; cannot send Slack interactive reply"
|
||||
)
|
||||
return
|
||||
|
||||
ctx = dict(delivery_context or {})
|
||||
if not thread_ts:
|
||||
thread_ts = str(ctx.get("thread_id", "") or "").strip()
|
||||
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
|
||||
str(ctx.get("workspace_id", "") or "").strip()
|
||||
)
|
||||
if not bot_token:
|
||||
self._adapter_logger().warning(
|
||||
"Slack interactive reply dropped: no workspace token available (workspace=%s)",
|
||||
workspace_id or "legacy",
|
||||
)
|
||||
return
|
||||
|
||||
elements: list[dict] = []
|
||||
for idx, button in enumerate(buttons[:5]):
|
||||
value = str(button.get("value", "") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
label = str(button.get("label", "") or "OpenClaw").strip()[:75]
|
||||
action_id = str(
|
||||
button.get("action_type")
|
||||
or button.get("action_id")
|
||||
or f"openclaw.{idx}"
|
||||
).strip()[:255]
|
||||
element: Dict[str, Any] = {
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": label or "OpenClaw"},
|
||||
"value": value[:2000],
|
||||
"action_id": action_id or f"openclaw.{idx}",
|
||||
}
|
||||
style = self._adapter_style_to_slack(str(button.get("style", "") or ""))
|
||||
if style:
|
||||
element["style"] = style
|
||||
elements.append(element)
|
||||
if not elements:
|
||||
if text:
|
||||
await self._send_reply(
|
||||
channel_id=channel_id,
|
||||
text=text,
|
||||
thread_ts=thread_ts,
|
||||
delivery_context=ctx,
|
||||
)
|
||||
return
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel": channel_id,
|
||||
"text": text or "OpenClaw",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (text or "OpenClaw")[:3000],
|
||||
},
|
||||
},
|
||||
{"type": "actions", "elements": elements},
|
||||
],
|
||||
}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {bot_token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
try:
|
||||
async with _aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=_aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
if installation_id:
|
||||
self._installation_manager.mark_api_error(
|
||||
installation_id,
|
||||
error_code=f"http_{resp.status}",
|
||||
status_code=resp.status,
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"path": "chat.postMessage",
|
||||
"interactive": True,
|
||||
},
|
||||
)
|
||||
return
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
if installation_id:
|
||||
self._installation_manager.mark_api_error(
|
||||
installation_id,
|
||||
error_code=str(data.get("error", "unknown")),
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"path": "chat.postMessage",
|
||||
"interactive": True,
|
||||
},
|
||||
)
|
||||
elif installation_id:
|
||||
self._installation_manager.mark_installation_health(
|
||||
installation_id,
|
||||
health_code="ok",
|
||||
reason="chat_post_message_interactive_ok",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
except Exception as e:
|
||||
self._adapter_logger().warning("Slack interactive reply failed: %s", e)
|
||||
|
||||
async def _send_reply(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
thread_ts: str = "",
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Send a message via Slack Web API (chat.postMessage)."""
|
||||
ctx = dict(delivery_context or {})
|
||||
if not thread_ts:
|
||||
thread_ts = str(ctx.get("thread_id", "") or "").strip()
|
||||
decision = decide_reply_visibility(
|
||||
delivery_context=ctx,
|
||||
platform="slack",
|
||||
channel_kind=self._adapter_channel_kind(channel_id),
|
||||
in_thread=bool(thread_ts),
|
||||
text=text,
|
||||
)
|
||||
if decision.suppressed:
|
||||
self._adapter_logger().info(
|
||||
"Suppressed Slack reply channel=%s reason=%s",
|
||||
channel_id,
|
||||
decision.reason,
|
||||
)
|
||||
return
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
self._adapter_logger().warning(
|
||||
"aiohttp not available; cannot send Slack reply"
|
||||
)
|
||||
return
|
||||
|
||||
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
|
||||
str(ctx.get("workspace_id", "") or "").strip()
|
||||
)
|
||||
if not bot_token:
|
||||
self._adapter_logger().warning(
|
||||
"Slack reply dropped: no workspace token available (workspace=%s)",
|
||||
workspace_id or "legacy",
|
||||
)
|
||||
return
|
||||
|
||||
url = "https://slack.com/api/chat.postMessage"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {bot_token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"channel": channel_id,
|
||||
"text": text,
|
||||
}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
|
||||
try:
|
||||
async with _aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=_aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
if installation_id:
|
||||
self._installation_manager.mark_api_error(
|
||||
installation_id,
|
||||
error_code=f"http_{resp.status}",
|
||||
status_code=resp.status,
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"path": "chat.postMessage",
|
||||
},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
f"Slack API error: status={resp.status} body={body[:200]}"
|
||||
)
|
||||
else:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
if installation_id:
|
||||
self._installation_manager.mark_api_error(
|
||||
installation_id,
|
||||
error_code=str(data.get("error", "unknown")),
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"path": "chat.postMessage",
|
||||
},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
f"Slack API error: {data.get('error', 'unknown')}"
|
||||
)
|
||||
elif installation_id:
|
||||
self._installation_manager.mark_installation_health(
|
||||
installation_id,
|
||||
health_code="ok",
|
||||
reason="chat_post_message_ok",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
except Exception as e:
|
||||
self._adapter_logger().warning(f"Slack reply failed: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Platform contract: send_message / send_image
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Platform contract: send text message."""
|
||||
await self._send_reply(
|
||||
channel_id=channel_id,
|
||||
text=text,
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
channel_id: str,
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
delivery_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Platform contract: send image (Slack files.upload)."""
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
except ImportError:
|
||||
self._adapter_logger().warning(
|
||||
"aiohttp not available; cannot upload Slack image"
|
||||
)
|
||||
return
|
||||
|
||||
ctx = dict(delivery_context or {})
|
||||
thread_ts = str(ctx.get("thread_id", "") or "").strip()
|
||||
installation_id, bot_token, workspace_id = self._resolve_workspace_credentials(
|
||||
str(ctx.get("workspace_id", "") or "").strip()
|
||||
)
|
||||
if not bot_token:
|
||||
self._adapter_logger().warning(
|
||||
"Slack image dropped: no workspace token available (workspace=%s)",
|
||||
workspace_id or "legacy",
|
||||
)
|
||||
return
|
||||
|
||||
url = "https://slack.com/api/files.upload"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {bot_token}",
|
||||
}
|
||||
data = _aiohttp.FormData()
|
||||
data.add_field("file", image_data, filename=filename, content_type="image/png")
|
||||
data.add_field("channels", channel_id)
|
||||
if caption:
|
||||
data.add_field("initial_comment", caption)
|
||||
if thread_ts:
|
||||
data.add_field("thread_ts", thread_ts)
|
||||
|
||||
try:
|
||||
async with _aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
if installation_id:
|
||||
self._installation_manager.mark_api_error(
|
||||
installation_id,
|
||||
error_code=f"http_{resp.status}",
|
||||
status_code=resp.status,
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"path": "files.upload",
|
||||
},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
f"Slack file upload error: status={resp.status}"
|
||||
)
|
||||
else:
|
||||
resp_data = await resp.json()
|
||||
if not resp_data.get("ok"):
|
||||
if installation_id:
|
||||
self._installation_manager.mark_api_error(
|
||||
installation_id,
|
||||
error_code=str(resp_data.get("error", "unknown")),
|
||||
details={
|
||||
"workspace_id": workspace_id,
|
||||
"path": "files.upload",
|
||||
},
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
f"Slack file upload error: {resp_data.get('error')}"
|
||||
)
|
||||
elif installation_id:
|
||||
self._installation_manager.mark_installation_health(
|
||||
installation_id,
|
||||
health_code="ok",
|
||||
reason="files_upload_ok",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
except Exception as e:
|
||||
self._adapter_logger().warning(f"Slack image upload failed: {e}")
|
||||
@@ -0,0 +1,497 @@
|
||||
"""Owned Slack signed-ingress and interaction transaction mixin."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from ..contract import CommandRequest
|
||||
|
||||
# ruff: noqa: SIM102, UP006, UP035, UP045 -- preserve frozen behavior/signatures.
|
||||
# mypy: disable-error-code="attr-defined,no-any-return"
|
||||
|
||||
|
||||
class SlackIngressMixin:
|
||||
async def handle_event(self, request):
|
||||
"""POST handler for Slack Events API."""
|
||||
_, web = self._adapter_import_aiohttp_web()
|
||||
|
||||
try:
|
||||
body_bytes = await request.read()
|
||||
except Exception:
|
||||
return self._adapter_make_response(web, status=400, text="Bad request")
|
||||
|
||||
# -- Step 1: Signature verification (fail-closed) --
|
||||
timestamp = ""
|
||||
signature = ""
|
||||
if hasattr(request, "headers"):
|
||||
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||
signature = request.headers.get("X-Slack-Signature", "")
|
||||
|
||||
if not self._adapter_verify_slack_signature(
|
||||
signing_secret=self.config.slack_signing_secret or "",
|
||||
timestamp=timestamp,
|
||||
body=body_bytes,
|
||||
signature=signature,
|
||||
):
|
||||
self._adapter_logger().warning(
|
||||
"Slack signature verification failed (rejected)"
|
||||
)
|
||||
return self._adapter_make_response(
|
||||
web, status=401, text="Invalid signature"
|
||||
)
|
||||
|
||||
# -- Step 2: Parse payload --
|
||||
try:
|
||||
payload = json.loads(body_bytes)
|
||||
except json.JSONDecodeError:
|
||||
return self._adapter_make_response(web, status=400, text="Bad JSON")
|
||||
|
||||
# -- Step 3: url_verification challenge (Webhook only) --
|
||||
if payload.get("type") == "url_verification":
|
||||
challenge = payload.get("challenge", "")
|
||||
return self._adapter_make_json_response(web, {"challenge": challenge})
|
||||
|
||||
# -- Step 4: Process event --
|
||||
try:
|
||||
await self.process_event_payload(payload)
|
||||
except ValueError:
|
||||
return self._adapter_make_response(web, status=400, text="Bad Request")
|
||||
return self._adapter_make_response(web, status=200, text="OK")
|
||||
|
||||
async def handle_interaction(self, request):
|
||||
"""POST handler for Slack Block Kit interactivity callbacks."""
|
||||
_, web = self._adapter_import_aiohttp_web()
|
||||
|
||||
try:
|
||||
body_bytes = await request.read()
|
||||
except Exception:
|
||||
return self._adapter_make_response(web, status=400, text="Bad request")
|
||||
|
||||
timestamp = ""
|
||||
signature = ""
|
||||
if hasattr(request, "headers"):
|
||||
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
||||
signature = request.headers.get("X-Slack-Signature", "")
|
||||
|
||||
if not self._adapter_verify_slack_signature(
|
||||
signing_secret=self.config.slack_signing_secret or "",
|
||||
timestamp=timestamp,
|
||||
body=body_bytes,
|
||||
signature=signature,
|
||||
):
|
||||
self._adapter_logger().warning(
|
||||
"Slack interaction signature verification failed (rejected)"
|
||||
)
|
||||
return self._adapter_make_response(
|
||||
web, status=401, text="Invalid signature"
|
||||
)
|
||||
|
||||
parsed = parse_qs(body_bytes.decode("utf-8"), keep_blank_values=True)
|
||||
raw_payload = (parsed.get("payload") or [""])[0]
|
||||
if not raw_payload:
|
||||
return self._adapter_make_response(web, status=400, text="Missing payload")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except json.JSONDecodeError:
|
||||
return self._adapter_make_response(web, status=400, text="Bad payload")
|
||||
if not isinstance(payload, dict):
|
||||
return self._adapter_make_response(web, status=400, text="Bad payload")
|
||||
|
||||
try:
|
||||
routed = await self.process_interaction_payload(payload)
|
||||
except ValueError:
|
||||
return self._adapter_make_response(web, status=400, text="Bad Request")
|
||||
except Exception as exc:
|
||||
safe_text = self._adapter_safe_external_error_text(
|
||||
"Slack interaction failed", exc
|
||||
)
|
||||
self._adapter_logger().warning("Slack interaction failed: %s", safe_text)
|
||||
return self._adapter_make_response(web, status=500, text=safe_text)
|
||||
|
||||
# Slack requires a fast acknowledgement for interactivity requests.
|
||||
# Keep the external response bounded; detailed action results are routed
|
||||
# through the existing reply/deferred-response surfaces.
|
||||
return self._adapter_make_json_response(
|
||||
web, {"ok": True, "routed": bool(routed)}
|
||||
)
|
||||
|
||||
async def process_event_payload(self, payload: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Shared event processing path for both webhook and socket mode transports.
|
||||
"""
|
||||
if payload.get("type") != "event_callback":
|
||||
return
|
||||
|
||||
event = payload.get("event", {})
|
||||
event_id = payload.get("event_id", "")
|
||||
event_type = event.get("type", "")
|
||||
workspace_id = self._installation_manager.extract_workspace_id(payload)
|
||||
|
||||
if event_type in ("app_uninstalled", "tokens_revoked", "app_rate_limited"):
|
||||
if workspace_id:
|
||||
self._handle_lifecycle_event(workspace_id, event_type)
|
||||
return
|
||||
|
||||
# -- Step 5: Replay / dedupe guard --
|
||||
if not event_id:
|
||||
self._adapter_logger().warning("Slack event missing event_id (rejected)")
|
||||
raise ValueError("Missing event_id")
|
||||
|
||||
if not self._replay_guard.check_and_record(event_id):
|
||||
self._adapter_logger().debug(
|
||||
f"Slack duplicate event_id={event_id} (accepted, no-op)"
|
||||
)
|
||||
return
|
||||
|
||||
# -- Step 6: Bot-loop prevention --
|
||||
# Resolve bot user ID from authorizations or cache.
|
||||
bot_user_id = self._get_bot_user_id(payload, workspace_id)
|
||||
|
||||
sender_id = event.get("user", "")
|
||||
if sender_id and bot_user_id and sender_id == bot_user_id:
|
||||
return
|
||||
|
||||
if event.get("bot_id"):
|
||||
return
|
||||
|
||||
subtype = event.get("subtype", "")
|
||||
if subtype and subtype not in ("", "file_share"):
|
||||
return
|
||||
|
||||
# -- Step 7: Event normalization --
|
||||
text = event.get("text", "").strip()
|
||||
channel_id = event.get("channel", "")
|
||||
thread_ts = event.get("thread_ts", "")
|
||||
message_ts = event.get("ts", "")
|
||||
|
||||
if event_type not in ("message", "app_mention"):
|
||||
return
|
||||
|
||||
if not text or not sender_id:
|
||||
return
|
||||
|
||||
# S67: Require mention in group channels.
|
||||
is_dm = channel_id.startswith("D")
|
||||
mentioned_bot = event_type == "app_mention" or (
|
||||
bool(bot_user_id) and f"<@{bot_user_id}>" in text
|
||||
)
|
||||
if not is_dm and self.config.slack_require_mention:
|
||||
if event_type != "app_mention":
|
||||
if bot_user_id and f"<@{bot_user_id}>" not in text:
|
||||
return
|
||||
|
||||
if bot_user_id:
|
||||
text = text.replace(f"<@{bot_user_id}>", "").strip()
|
||||
|
||||
# -- Step 8: Allowlist checks (S67) --
|
||||
if self._user_allowlist.entries:
|
||||
user_result = self._user_allowlist.evaluate(sender_id)
|
||||
if user_result.decision == "deny":
|
||||
self._adapter_logger().warning(
|
||||
f"Slack user {sender_id} denied by allowlist"
|
||||
)
|
||||
return
|
||||
|
||||
if self._channel_allowlist.entries and channel_id:
|
||||
chan_result = self._channel_allowlist.evaluate(channel_id)
|
||||
if chan_result.decision == "deny":
|
||||
self._adapter_logger().warning(
|
||||
f"Slack channel {channel_id} denied by allowlist"
|
||||
)
|
||||
return
|
||||
|
||||
# -- Step 9: Build CommandRequest and route --
|
||||
req = CommandRequest(
|
||||
platform="slack",
|
||||
sender_id=sender_id,
|
||||
channel_id=channel_id,
|
||||
username=sender_id,
|
||||
message_id=event_id,
|
||||
text=text,
|
||||
timestamp=float(message_ts) if message_ts else time.time(),
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_ts
|
||||
or (message_ts if self.config.slack_reply_in_thread else ""),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.router.handle(req)
|
||||
resp_text = getattr(resp, "text", "")
|
||||
if not isinstance(resp_text, str):
|
||||
resp_text = str(resp_text) if resp_text is not None else ""
|
||||
|
||||
buttons = getattr(resp, "buttons", []) or []
|
||||
if resp_text or buttons:
|
||||
if buttons:
|
||||
await self._send_interactive_reply(
|
||||
channel_id=channel_id,
|
||||
text=resp_text or "OpenClaw",
|
||||
buttons=buttons,
|
||||
thread_ts=req.thread_id,
|
||||
delivery_context={
|
||||
"workspace_id": workspace_id,
|
||||
"thread_id": req.thread_id,
|
||||
"channel_kind": self._adapter_channel_kind(channel_id),
|
||||
"mentioned": mentioned_bot,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await self._send_reply(
|
||||
channel_id=channel_id,
|
||||
text=resp_text,
|
||||
thread_ts=req.thread_id,
|
||||
delivery_context={
|
||||
"workspace_id": workspace_id,
|
||||
"thread_id": req.thread_id,
|
||||
"channel_kind": self._adapter_channel_kind(channel_id),
|
||||
"mentioned": mentioned_bot,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
self._adapter_logger().error(
|
||||
"Slack event handling failed (error_type=%s)", type(e).__name__
|
||||
)
|
||||
|
||||
async def process_interaction_payload(self, payload: Dict[str, Any]) -> bool:
|
||||
interaction_type = str(payload.get("type", "") or "").strip()
|
||||
if interaction_type not in self._adapter_interaction_types():
|
||||
return False
|
||||
|
||||
request = self._build_interaction_request(payload)
|
||||
if request is None:
|
||||
return False
|
||||
|
||||
replay_key = self._interaction_replay_key(payload, request)
|
||||
if self._interaction_lifecycle is None: # pragma: no cover
|
||||
if not self._replay_guard.check_and_record(replay_key):
|
||||
self._adapter_logger().debug(
|
||||
"Slack duplicate interaction %s (accepted, no-op)", replay_key
|
||||
)
|
||||
return False
|
||||
claim = None
|
||||
else:
|
||||
claim = self._interaction_lifecycle.claim(
|
||||
replay_key,
|
||||
metadata={
|
||||
"platform": "slack",
|
||||
"workspace_id": request.workspace_id,
|
||||
"interaction_type": str(payload.get("type", "") or ""),
|
||||
},
|
||||
)
|
||||
if claim is not None and not claim.accepted:
|
||||
self._adapter_logger().debug(
|
||||
"Slack duplicate interaction %s state=%s code=%s (accepted, no-op)",
|
||||
replay_key,
|
||||
claim.record.state,
|
||||
claim.code,
|
||||
)
|
||||
return False
|
||||
|
||||
# IMPORTANT: interactive run-like payloads must be routed through the same
|
||||
# approval semantics as text commands. Untrusted users get approval forced
|
||||
# before CommandRouter sees the request, avoiding a parallel bypass path.
|
||||
if request.text.startswith("/run") and not (
|
||||
self.router._is_admin(request) or self.router._is_trusted(request)
|
||||
):
|
||||
request.text = self._adapter_force_approval_command(request.text)
|
||||
|
||||
try:
|
||||
response = await self.router.handle(request)
|
||||
except Exception:
|
||||
# IMPORTANT: only failures before router completion are retryable.
|
||||
# Once router.handle returns, duplicate user actions must not reroute.
|
||||
if self._interaction_lifecycle is not None:
|
||||
self._interaction_lifecycle.release_retryable(
|
||||
replay_key, reason="slack_interaction_failed_before_commit"
|
||||
)
|
||||
raise
|
||||
|
||||
if self._interaction_lifecycle is not None:
|
||||
self._interaction_lifecycle.commit_success(replay_key, reason="routed")
|
||||
response_text = str(getattr(response, "text", "") or "").strip()
|
||||
response_buttons = getattr(response, "buttons", []) or []
|
||||
if response_text or response_buttons:
|
||||
if response_buttons:
|
||||
await self._send_interactive_reply(
|
||||
channel_id=request.channel_id,
|
||||
text=response_text or "Action processed.",
|
||||
buttons=response_buttons,
|
||||
thread_ts=request.thread_id,
|
||||
delivery_context={
|
||||
"workspace_id": request.workspace_id,
|
||||
"thread_id": request.thread_id,
|
||||
},
|
||||
)
|
||||
elif response_text:
|
||||
await self._send_reply(
|
||||
channel_id=request.channel_id,
|
||||
text=response_text,
|
||||
thread_ts=request.thread_id,
|
||||
delivery_context={
|
||||
"workspace_id": request.workspace_id,
|
||||
"thread_id": request.thread_id,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
def _build_interaction_request(
|
||||
self, payload: Dict[str, Any]
|
||||
) -> Optional[CommandRequest]:
|
||||
interaction_type = str(payload.get("type", "") or "").strip()
|
||||
command_text = self._extract_interaction_command(payload)
|
||||
if not command_text:
|
||||
return None
|
||||
|
||||
team = payload.get("team") or {}
|
||||
user = payload.get("user") or {}
|
||||
container = payload.get("container") or {}
|
||||
channel = payload.get("channel") or {}
|
||||
view = payload.get("view") or {}
|
||||
message = payload.get("message") or {}
|
||||
action = self._first_action(payload)
|
||||
|
||||
workspace_id = self._adapter_first_non_empty(
|
||||
team.get("id"),
|
||||
payload.get("team_id"),
|
||||
(
|
||||
payload.get("enterprise", {}).get("id")
|
||||
if isinstance(payload.get("enterprise"), dict)
|
||||
else ""
|
||||
),
|
||||
)
|
||||
sender_id = self._adapter_first_non_empty(
|
||||
user.get("id"), payload.get("user_id")
|
||||
)
|
||||
channel_id = self._adapter_first_non_empty(
|
||||
channel.get("id"),
|
||||
container.get("channel_id"),
|
||||
payload.get("channel_id"),
|
||||
)
|
||||
message_id = self._adapter_first_non_empty(
|
||||
view.get("id"),
|
||||
action.get("action_ts"),
|
||||
container.get("message_ts"),
|
||||
payload.get("trigger_id"),
|
||||
f"slack-interaction-{int(time.time())}",
|
||||
)
|
||||
thread_id = self._adapter_first_non_empty(
|
||||
container.get("thread_ts"),
|
||||
message.get("thread_ts") if isinstance(message, dict) else "",
|
||||
container.get("message_ts"),
|
||||
)
|
||||
if not thread_id and self.config.slack_reply_in_thread:
|
||||
thread_id = self._adapter_first_non_empty(
|
||||
container.get("message_ts"), message.get("ts")
|
||||
)
|
||||
|
||||
return CommandRequest(
|
||||
platform="slack",
|
||||
sender_id=sender_id,
|
||||
channel_id=channel_id or sender_id,
|
||||
username=self._adapter_first_non_empty(
|
||||
user.get("username"), user.get("name"), sender_id
|
||||
),
|
||||
message_id=message_id,
|
||||
text=command_text,
|
||||
timestamp=time.time(),
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
metadata={
|
||||
"interactive_callback": True,
|
||||
"interaction_type": interaction_type,
|
||||
"action_id": self._adapter_first_non_empty(
|
||||
action.get("action_id"), view.get("callback_id")
|
||||
),
|
||||
"response_url": str(payload.get("response_url", "") or ""),
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_interaction_command(self, payload: Dict[str, Any]) -> str:
|
||||
interaction_type = str(payload.get("type", "") or "").strip()
|
||||
if interaction_type == "block_actions":
|
||||
action = self._first_action(payload)
|
||||
selected = action.get("selected_option") or {}
|
||||
value = self._adapter_first_non_empty(
|
||||
action.get("value"),
|
||||
selected.get("value") if isinstance(selected, dict) else "",
|
||||
action.get("action_id"),
|
||||
)
|
||||
parsed = self._adapter_json_loads_safe(value)
|
||||
return self._adapter_first_non_empty(
|
||||
parsed.get("command"), parsed.get("value"), value
|
||||
)
|
||||
if interaction_type == "view_submission":
|
||||
view = payload.get("view") or {}
|
||||
private_meta = self._adapter_first_non_empty(view.get("private_metadata"))
|
||||
parsed = self._adapter_json_loads_safe(private_meta)
|
||||
if parsed:
|
||||
return self._adapter_first_non_empty(
|
||||
parsed.get("command"), parsed.get("value")
|
||||
)
|
||||
if private_meta:
|
||||
return private_meta
|
||||
state = (view.get("state") or {}).get("values") or {}
|
||||
return self._extract_command_from_view_state(state)
|
||||
if interaction_type == "workflow_step_execute":
|
||||
workflow_step = payload.get("workflow_step") or {}
|
||||
inputs = workflow_step.get("inputs") or {}
|
||||
command = inputs.get("command") or {}
|
||||
if isinstance(command, dict):
|
||||
return self._adapter_first_non_empty(command.get("value"))
|
||||
return self._adapter_first_non_empty(workflow_step.get("callback_id"))
|
||||
return ""
|
||||
|
||||
def _extract_command_from_view_state(self, state: Dict[str, Any]) -> str:
|
||||
if not isinstance(state, dict):
|
||||
return ""
|
||||
for block_value in state.values():
|
||||
if not isinstance(block_value, dict):
|
||||
continue
|
||||
for action_value in block_value.values():
|
||||
if not isinstance(action_value, dict):
|
||||
continue
|
||||
candidate = self._adapter_first_non_empty(
|
||||
action_value.get("value"),
|
||||
(
|
||||
(action_value.get("selected_option") or {}).get("value")
|
||||
if isinstance(action_value.get("selected_option"), dict)
|
||||
else ""
|
||||
),
|
||||
)
|
||||
parsed = self._adapter_json_loads_safe(candidate)
|
||||
command = self._adapter_first_non_empty(
|
||||
parsed.get("command"), parsed.get("value"), candidate
|
||||
)
|
||||
if command:
|
||||
return command
|
||||
return ""
|
||||
|
||||
def _first_action(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
actions = payload.get("actions") or []
|
||||
if isinstance(actions, list) and actions and isinstance(actions[0], dict):
|
||||
return actions[0]
|
||||
return {}
|
||||
|
||||
def _interaction_replay_key(
|
||||
self, payload: Dict[str, Any], request: CommandRequest
|
||||
) -> str:
|
||||
action = self._first_action(payload)
|
||||
key_parts = [
|
||||
"interaction",
|
||||
str(payload.get("type", "") or ""),
|
||||
request.workspace_id,
|
||||
request.sender_id,
|
||||
request.channel_id,
|
||||
request.message_id,
|
||||
str(payload.get("trigger_id", "") or ""),
|
||||
str(action.get("action_id", "") or ""),
|
||||
str(action.get("action_ts", "") or ""),
|
||||
request.text,
|
||||
]
|
||||
return ":".join(key_parts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slack Web API reply
|
||||
# ------------------------------------------------------------------
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Owned Slack installation, OAuth, and workspace-identity mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035, UP045 -- preserve frozen facade annotations.
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# mypy: disable-error-code="attr-defined,has-type,no-any-return"
|
||||
|
||||
|
||||
class SlackInstallationMixin:
|
||||
async def handle_oauth_install(self, request):
|
||||
_, web = self._adapter_import_aiohttp_web()
|
||||
if not self._installation_manager.can_handle_oauth():
|
||||
return self._adapter_make_response(
|
||||
web, status=503, text="Slack OAuth not configured"
|
||||
)
|
||||
state = self._installation_manager.issue_install_state()
|
||||
return self._adapter_make_redirect_response(
|
||||
web, self._installation_manager.build_install_url(state)
|
||||
)
|
||||
|
||||
async def handle_oauth_callback(self, request):
|
||||
_, web = self._adapter_import_aiohttp_web()
|
||||
if not self._installation_manager.can_handle_oauth():
|
||||
return self._adapter_make_response(
|
||||
web, status=503, text="Slack OAuth not configured"
|
||||
)
|
||||
query = getattr(request, "query", {}) or {}
|
||||
if query.get("error"):
|
||||
return self._adapter_make_response(
|
||||
web,
|
||||
status=400,
|
||||
text=f"Slack OAuth rejected: {query.get('error')}",
|
||||
)
|
||||
state = str(query.get("state", "") or "").strip()
|
||||
code = str(query.get("code", "") or "").strip()
|
||||
if not state or not code:
|
||||
return self._adapter_make_response(
|
||||
web, status=400, text="Missing OAuth callback fields"
|
||||
)
|
||||
if not self._installation_manager.consume_install_state(state):
|
||||
return self._adapter_make_response(
|
||||
web, status=400, text="Invalid or replayed OAuth state"
|
||||
)
|
||||
try:
|
||||
payload = await self._installation_manager.exchange_code(code)
|
||||
installation = self._installation_manager.upsert_from_oauth_payload(payload)
|
||||
return self._adapter_make_response(
|
||||
web,
|
||||
status=200,
|
||||
text=(
|
||||
"Slack installation complete for "
|
||||
f"{installation.workspace_id} ({installation.installation_id})."
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
safe_text = self._adapter_safe_external_error_text(
|
||||
"Slack OAuth processing failed", exc
|
||||
)
|
||||
self._adapter_logger().warning("Slack OAuth callback failed: %s", safe_text)
|
||||
return self._adapter_make_response(
|
||||
web,
|
||||
status=502,
|
||||
text=safe_text,
|
||||
)
|
||||
|
||||
def _get_bot_user_id(self, payload: Dict[str, Any], workspace_id: str) -> str:
|
||||
candidate = ""
|
||||
if workspace_id and workspace_id in self._bot_user_ids:
|
||||
return self._bot_user_ids[workspace_id]
|
||||
if self._bot_user_id:
|
||||
return self._bot_user_id
|
||||
authorizations = payload.get("authorizations", [])
|
||||
if authorizations and isinstance(authorizations, list):
|
||||
candidate = str((authorizations[0] or {}).get("user_id", "") or "").strip()
|
||||
if candidate:
|
||||
self._bot_user_id = candidate
|
||||
if workspace_id:
|
||||
self._bot_user_ids[workspace_id] = candidate
|
||||
return candidate
|
||||
if workspace_id:
|
||||
workspace_resolution, _ = (
|
||||
self._installation_manager.resolve_workspace_tokens(workspace_id)
|
||||
)
|
||||
candidate = self._installation_manager.bot_user_id_for_installation(
|
||||
workspace_resolution.installation if workspace_resolution.ok else None
|
||||
)
|
||||
if candidate:
|
||||
self._bot_user_ids[workspace_id] = candidate
|
||||
if self._bot_user_id is None:
|
||||
self._bot_user_id = candidate
|
||||
return candidate
|
||||
|
||||
def _resolve_workspace_credentials(
|
||||
self, workspace_id: str
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
workspace_id = str(workspace_id or "").strip()
|
||||
if workspace_id:
|
||||
resolution, tokens = self._installation_manager.resolve_workspace_tokens(
|
||||
workspace_id
|
||||
)
|
||||
if resolution.ok and resolution.installation is not None:
|
||||
bot_token = tokens.get("bot_token")
|
||||
if bot_token:
|
||||
self._installation_manager.mark_resolution_success(
|
||||
resolution.installation.installation_id, workspace_id
|
||||
)
|
||||
return (
|
||||
resolution.installation.installation_id,
|
||||
bot_token,
|
||||
workspace_id,
|
||||
)
|
||||
self._adapter_logger().warning(
|
||||
"Slack workspace %s resolved without bot token secret", workspace_id
|
||||
)
|
||||
return (
|
||||
resolution.installation.installation_id,
|
||||
None,
|
||||
workspace_id,
|
||||
)
|
||||
if (
|
||||
not self._installation_manager.oauth_enabled
|
||||
and self.config.slack_bot_token
|
||||
):
|
||||
return (None, self.config.slack_bot_token, workspace_id)
|
||||
self._adapter_logger().warning(
|
||||
"Slack workspace resolution failed for %s: %s (%s)",
|
||||
workspace_id,
|
||||
resolution.reject_reason,
|
||||
resolution.health_code,
|
||||
)
|
||||
return (None, None, workspace_id)
|
||||
if self.config.slack_bot_token:
|
||||
return (None, self.config.slack_bot_token, "")
|
||||
return (None, None, workspace_id)
|
||||
|
||||
def _handle_lifecycle_event(self, workspace_id: str, event_type: str) -> None:
|
||||
installation_id = self._installation_manager.installation_id_for_workspace(
|
||||
workspace_id
|
||||
)
|
||||
try:
|
||||
if event_type == "app_uninstalled":
|
||||
self._installation_manager.mark_installation_health(
|
||||
installation_id,
|
||||
health_code="revoked",
|
||||
reason="slack_app_uninstalled",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
self._installation_manager.uninstall_installation(
|
||||
installation_id, reason="slack_app_uninstalled"
|
||||
)
|
||||
elif event_type == "tokens_revoked":
|
||||
self._installation_manager.mark_installation_health(
|
||||
installation_id,
|
||||
health_code="invalid_token",
|
||||
reason="slack_tokens_revoked",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
elif event_type == "app_rate_limited":
|
||||
self._installation_manager.mark_installation_health(
|
||||
installation_id,
|
||||
health_code="degraded",
|
||||
reason="slack_app_rate_limited",
|
||||
details={"workspace_id": workspace_id},
|
||||
)
|
||||
except ValueError:
|
||||
self._adapter_logger().warning(
|
||||
"Slack lifecycle event for unbound workspace %s (%s)",
|
||||
workspace_id,
|
||||
event_type,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+20
-1027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
"""Owned status, approval, schedule, and introspection command mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035, UP045 -- preserve the frozen public annotations.
|
||||
# mypy: disable-error-code="attr-defined"
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import List, Optional
|
||||
|
||||
from .contract import CommandRequest, CommandResponse
|
||||
from .jobs_summary import JobsContractError, format_jobs_summary, format_queue_fallback
|
||||
|
||||
try:
|
||||
from services.reasoning_redaction import sanitize_operator_payload
|
||||
except Exception: # pragma: no cover - connector tests may stub import graph
|
||||
|
||||
def sanitize_operator_payload(value, **_): # type: ignore
|
||||
return value
|
||||
|
||||
|
||||
class RouterAdminMixin:
|
||||
async def _handle_status(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
health = await self.client.get_health()
|
||||
queue = await self.client.get_prompt_queue()
|
||||
|
||||
# New standardized response handling
|
||||
health_ok = health.get("ok")
|
||||
|
||||
status_icon = "Online" if health_ok else "Offline"
|
||||
details = []
|
||||
|
||||
if health_ok:
|
||||
data = health.get("data", {})
|
||||
stats = data.get("stats", {})
|
||||
details.append(f"Logs: {stats.get('logs_processed', 0)}")
|
||||
details.append(f"Errors: {stats.get('errors_captured', 0)}")
|
||||
|
||||
q_res = queue.get("data", {})
|
||||
q_rem = q_res.get("exec_info", {}).get("queue_remaining", 0)
|
||||
details.append(f"Queue: {q_rem}")
|
||||
else:
|
||||
details.append(f"Error: {health.get('error')}")
|
||||
|
||||
return CommandResponse(
|
||||
text=f"[{status_icon}] System Status\n"
|
||||
+ "\n".join(f"- {d}" for d in details)
|
||||
)
|
||||
|
||||
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
|
||||
"""
|
||||
F32 WP3: Check if admin token is configured before running admin commands.
|
||||
Fail-fast with clear error message instead of 403/500 later.
|
||||
|
||||
IMPORTANT (recurring CI failure mode):
|
||||
- Admin-only commands are gated by BOTH:
|
||||
(1) sender is an admin user, AND
|
||||
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
|
||||
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
|
||||
otherwise they will correctly receive the config error response.
|
||||
"""
|
||||
if not self.config.admin_token:
|
||||
return CommandResponse(
|
||||
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
|
||||
)
|
||||
return None
|
||||
|
||||
async def _handle_approvals_list(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
res = await self.client.get_approvals()
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(
|
||||
text=f"[Error] Failed to list approvals: {res.get('error')}"
|
||||
)
|
||||
|
||||
items = res.get("items", [])
|
||||
if not items:
|
||||
return CommandResponse(text="No pending approvals.")
|
||||
|
||||
pending_count = res.get("pending_count")
|
||||
lines = []
|
||||
buttons = []
|
||||
for i in items:
|
||||
# IMPORTANT (stability): the backend approval schema uses:
|
||||
# `approval_id`, `template_id`, `status`, `requested_by`, `source`.
|
||||
# Do not “simplify” these keys to `id/description/requester` unless you also
|
||||
# update the backend API + all tests. This mismatch previously caused silent
|
||||
# bad output and brittle regressions.
|
||||
approval_id = i.get("approval_id") or i.get("id") or "unknown"
|
||||
template_id = i.get("template_id") or "unknown"
|
||||
status = i.get("status") or "unknown"
|
||||
requested_by = i.get("requested_by") or "unknown"
|
||||
source = i.get("source") or "unknown"
|
||||
|
||||
lines.append(
|
||||
f"- {approval_id} [{status}] template={template_id} by={requested_by} source={source}"
|
||||
)
|
||||
for i in items[:3]:
|
||||
approval_id = i.get("approval_id") or i.get("id") or "unknown"
|
||||
short_id = str(approval_id)[:8]
|
||||
buttons.append(
|
||||
{
|
||||
"label": f"Approve {short_id}",
|
||||
"value": f"/approve {approval_id}",
|
||||
"action_type": "approval.approve",
|
||||
"approval_id": approval_id,
|
||||
"style": "primary",
|
||||
}
|
||||
)
|
||||
buttons.append(
|
||||
{
|
||||
"label": f"Reject {short_id}",
|
||||
"value": f"/reject {approval_id}",
|
||||
"action_type": "approval.reject",
|
||||
"approval_id": approval_id,
|
||||
"style": "danger",
|
||||
}
|
||||
)
|
||||
|
||||
header = "Pending Approvals"
|
||||
if isinstance(pending_count, int):
|
||||
header += f" ({pending_count})"
|
||||
return CommandResponse(
|
||||
text=header + ":\n" + "\n".join(lines),
|
||||
buttons=buttons,
|
||||
)
|
||||
|
||||
async def _handle_approve(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if not args:
|
||||
return CommandResponse(text="Usage: /approve <id>")
|
||||
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
# Assuming auto_execute=True by default for chat logic
|
||||
res = await self.client.approve_request(args[0], auto_execute=True)
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(text=f"[Failed] {res.get('error')}")
|
||||
|
||||
data = res.get("data", {})
|
||||
msg = f"[Approved] {args[0]}"
|
||||
|
||||
# Phase 4: Show execution result
|
||||
if "prompt_id" in data:
|
||||
pid = data["prompt_id"]
|
||||
msg += f"\nExecuted: {pid}"
|
||||
if self.poller:
|
||||
# Approval request might have come from different flow, but usually user invoking /approve
|
||||
# wants the result. Using current req context is safest assumption for "ChatOps".
|
||||
self.poller.track_job(
|
||||
pid,
|
||||
req.platform,
|
||||
req.channel_id,
|
||||
req.sender_id,
|
||||
delivery_context=self._delivery_context(req),
|
||||
)
|
||||
elif data.get("executed") is False:
|
||||
msg += "\n(Not Executed)"
|
||||
if err := data.get("execution_error"):
|
||||
msg += f"\nError: {err}"
|
||||
|
||||
return CommandResponse(text=msg)
|
||||
|
||||
async def _handle_reject(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if not args:
|
||||
return CommandResponse(text="Usage: /reject <id> [reason]")
|
||||
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
|
||||
res = await self.client.reject_request(args[0], reason)
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(text=f"[Failed] {res.get('error')}")
|
||||
|
||||
return CommandResponse(text=f"[Rejected] {args[0]}")
|
||||
|
||||
async def _handle_schedules_list(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
res = await self.client.get_schedules()
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(text=f"[Error] {res.get('error')}")
|
||||
|
||||
scheds = res.get("schedules", [])
|
||||
if not scheds:
|
||||
return CommandResponse(text="No schedules found.")
|
||||
|
||||
lines = []
|
||||
for s in scheds:
|
||||
status = "+" if s.get("enabled") else "-"
|
||||
lines.append(
|
||||
f"[{status}] {s.get('id')}: {s.get('cron')} - {s.get('template_id')}"
|
||||
)
|
||||
|
||||
return CommandResponse(text="Schedules:\n" + "\n".join(lines))
|
||||
|
||||
async def _handle_schedule_subcommand(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if len(args) < 2:
|
||||
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
|
||||
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
sub = args[0].lower()
|
||||
sid = args[1]
|
||||
|
||||
if sub == "run":
|
||||
res = await self.client.run_schedule(sid)
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(text=f"[Error] {res.get('error')}")
|
||||
return CommandResponse(text=f"[Success] Schedule {sid} triggered manually.")
|
||||
else:
|
||||
return CommandResponse(text="Not implemented yet.")
|
||||
|
||||
async def _handle_help(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"OpenClaw Connector\n"
|
||||
"/status - Check system health and queue\n"
|
||||
"/run <template> [prompt] [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
|
||||
"/stop [job_id ...] - Cancel jobs by id; no args sends Global Interrupt (Admin)\n"
|
||||
"/history <id> - Job details\n"
|
||||
"Admin Only:\n"
|
||||
"/jobs - Authoritative jobs summary\n"
|
||||
"/approvals - List pending approvals\n"
|
||||
"/approve <id>, /reject <id>\n"
|
||||
"/schedules, /schedule run <id>\n"
|
||||
"/trace <id> - Execution trace"
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_history(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if not args:
|
||||
return CommandResponse(text="Usage: /history <prompt_id>")
|
||||
res = await self.client.get_history(args[0])
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(text=f"[Error] {res.get('error')}")
|
||||
|
||||
# Simple format
|
||||
data = res.get("data", {})
|
||||
status = data.get("status", {}).get("status_str", "unknown")
|
||||
# Assuming backend returns a structure we can summarise
|
||||
return CommandResponse(
|
||||
text=f"Job {args[0]}: {status}\nFull details: not implemented in connector view yet."
|
||||
)
|
||||
|
||||
async def _handle_trace(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if not args:
|
||||
return CommandResponse(text="Usage: /trace <prompt_id>")
|
||||
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
res = await self.client.get_trace(args[0])
|
||||
if not res.get("ok"):
|
||||
return CommandResponse(text=f"[Error] {res.get('error')}")
|
||||
|
||||
# Dump trace
|
||||
sanitized = sanitize_operator_payload(res.get("data"))
|
||||
return CommandResponse(text=f"Trace {args[0]}: {str(sanitized)[:1000]}...")
|
||||
|
||||
async def _handle_jobs(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
res = await self.client.get_jobs()
|
||||
if not isinstance(res, Mapping):
|
||||
return CommandResponse(
|
||||
text="[Jobs] Could not fetch the authoritative jobs snapshot."
|
||||
)
|
||||
if res.get("ok") is True:
|
||||
try:
|
||||
return CommandResponse(text=format_jobs_summary(res.get("data")))
|
||||
except JobsContractError:
|
||||
return CommandResponse(
|
||||
text="[Jobs] Malformed or unsupported jobs response."
|
||||
)
|
||||
|
||||
status = res.get("status")
|
||||
error = res.get("error")
|
||||
access_denied = (
|
||||
isinstance(status, int)
|
||||
and not isinstance(status, bool)
|
||||
and status in {401, 403}
|
||||
)
|
||||
if access_denied:
|
||||
return CommandResponse(
|
||||
text="[Jobs] Access denied. Check connector Admin authorization and token posture."
|
||||
)
|
||||
fallback_allowed = isinstance(error, str) and (
|
||||
(status == 501 and error == "jobs_host_contract_unsupported")
|
||||
or (status == 503 and error == "jobs_backend_unavailable")
|
||||
)
|
||||
if fallback_allowed:
|
||||
return CommandResponse(
|
||||
text=format_queue_fallback(await self.client.get_prompt_queue())
|
||||
)
|
||||
return CommandResponse(
|
||||
text="[Jobs] Could not fetch the authoritative jobs snapshot."
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# F30: Chat LLM Assistant
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Owned chat and semantic-guard command-family mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035 -- preserve the frozen public annotations.
|
||||
# mypy: disable-error-code="attr-defined"
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .contract import CommandRequest, CommandResponse
|
||||
from .llm_client import LLMClient
|
||||
from .prompts import CHAT_STATUS_PROMPT, CHAT_SYSTEM_PROMPT
|
||||
from .semantic_guard import GuardAction
|
||||
|
||||
|
||||
class RouterChatMixin:
|
||||
async def _handle_chat(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
"""
|
||||
/chat [subcommand] <message>
|
||||
Subcommands: run, template, status
|
||||
Default: general chat
|
||||
|
||||
Security: Never auto-executes commands. Only suggests command text.
|
||||
"""
|
||||
llm = self._build_llm_client()
|
||||
|
||||
if not await llm.is_configured():
|
||||
return CommandResponse(
|
||||
text="[Chat Error] LLM not configured. Configure in OpenClaw Settings."
|
||||
)
|
||||
|
||||
# Parse subcommand
|
||||
if not args:
|
||||
return CommandResponse(
|
||||
text="Usage: /chat <message> or /chat run|template|status <request>"
|
||||
)
|
||||
|
||||
subcommand = args[0].lower()
|
||||
message = " ".join(args[1:]) if len(args) > 1 else ""
|
||||
|
||||
trust_level = "TRUSTED" if self._is_trusted(req) else "UNTRUSTED"
|
||||
|
||||
if subcommand == "run":
|
||||
return await self._chat_run(llm, message, trust_level)
|
||||
elif subcommand == "template":
|
||||
return await self._chat_template(llm, message)
|
||||
elif subcommand == "status":
|
||||
return await self._chat_status(llm)
|
||||
else:
|
||||
# General chat: first word is part of message
|
||||
full_message = " ".join(args)
|
||||
return await self._chat_general(llm, full_message, trust_level)
|
||||
|
||||
async def _chat_general(
|
||||
self, llm: LLMClient, message: str, trust_level: str
|
||||
) -> CommandResponse:
|
||||
"""General chat with assistant."""
|
||||
# S44: Semantic Guard Evaluation
|
||||
decision = self.semantic_guard.evaluate_request(message, {"trust": trust_level})
|
||||
|
||||
if decision.action == GuardAction.DENY:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"[Blocked] Request denied by semantic policy "
|
||||
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
|
||||
)
|
||||
)
|
||||
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
|
||||
response = await llm.chat(system_prompt, message)
|
||||
|
||||
# S44: Output Validation + SAFE_REPLY sanitization.
|
||||
try:
|
||||
response = self.semantic_guard.validate_output(
|
||||
response, "general", decision.action
|
||||
)
|
||||
except ValueError as e:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"[Validation Error] Assistant output invalid: "
|
||||
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'medium', 'action': 'deny', 'reason': str(e)})}"
|
||||
)
|
||||
)
|
||||
|
||||
if decision.action == GuardAction.SAFE_REPLY:
|
||||
safe_response = (
|
||||
response
|
||||
or "I can help with general guidance, but commands are restricted for this request."
|
||||
)
|
||||
return CommandResponse(
|
||||
text=(
|
||||
f"[Safe Mode] {safe_response}\n\n"
|
||||
f"(Policy: {self._policy_kv(decision.to_contract())})"
|
||||
)
|
||||
)
|
||||
|
||||
return CommandResponse(text=response)
|
||||
|
||||
async def _chat_run(
|
||||
self, llm: LLMClient, request: str, trust_level: str
|
||||
) -> CommandResponse:
|
||||
"""Suggest a /run command based on user request."""
|
||||
if not request:
|
||||
return CommandResponse(
|
||||
text="Usage: /chat run <description of what you want>"
|
||||
)
|
||||
|
||||
# S44: Semantic Guard Evaluation
|
||||
decision = self.semantic_guard.evaluate_request(request, {"trust": trust_level})
|
||||
|
||||
if decision.action == GuardAction.DENY:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"[Blocked] Request denied by semantic policy "
|
||||
f"({decision.reason}). {self._policy_kv(decision.to_contract())}"
|
||||
)
|
||||
)
|
||||
|
||||
# Force Approval Override based on Risk
|
||||
force_approval_policy = decision.action == GuardAction.FORCE_APPROVAL
|
||||
|
||||
# Get available templates (simplified - could fetch from API)
|
||||
templates = "txt2img, img2img, upscale (examples)"
|
||||
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level=trust_level)
|
||||
user_prompt = f"""User wants to run a generation. Suggest a `/run` command.
|
||||
|
||||
Request: {request}
|
||||
Available templates: {templates}
|
||||
Trust level: {trust_level}
|
||||
|
||||
Remember: {"add --approval flag" if trust_level == "UNTRUSTED" else "no --approval needed"}.
|
||||
Output only the command in a code block."""
|
||||
|
||||
response = await llm.chat(system_prompt, user_prompt)
|
||||
|
||||
# S44: Output Structure Validation
|
||||
try:
|
||||
response = self.semantic_guard.validate_output(
|
||||
response, "run", decision.action
|
||||
)
|
||||
except ValueError as e:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"[Validation Error] Assistant output invalid: "
|
||||
f"{e}. {self._policy_kv({'code': 'semantic_output_invalid', 'severity': 'high', 'action': 'deny', 'reason': str(e)})}"
|
||||
)
|
||||
)
|
||||
|
||||
# R97: Command Firewall - Extract and Validate
|
||||
import re
|
||||
|
||||
cmd_match = re.search(r"```(?:bash)?\s*(.*?)\s*```", response, re.DOTALL)
|
||||
raw_cmd = cmd_match.group(1).strip() if cmd_match else response.strip()
|
||||
|
||||
# Validate through Firewall
|
||||
normalized = self.command_firewall.validate_suggestion(raw_cmd)
|
||||
|
||||
if not normalized.is_safe:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"[Safety Block] Assistant suggested unsafe command: "
|
||||
f"{normalized.safety_reason}. {self._policy_kv(normalized.to_contract())}"
|
||||
)
|
||||
)
|
||||
|
||||
# R97: Strict /run enforcement (Remediation for Medium Severity)
|
||||
# CRITICAL: keep this check. /chat run must never emit non-/run commands.
|
||||
if normalized.command != "/run":
|
||||
return CommandResponse(
|
||||
text=(
|
||||
"[Policy Block] Only /run commands are allowed in this mode. "
|
||||
f"Got: {normalized.command}. "
|
||||
f"{self._policy_kv({'code': 'firewall_non_run_command', 'severity': 'high', 'action': 'deny', 'reason': 'non_run_command_in_run_mode'})}"
|
||||
)
|
||||
)
|
||||
|
||||
# R97/S44: Apply Policy Overrides
|
||||
# If risk was elevated, ensure --approval is present
|
||||
if (
|
||||
force_approval_policy
|
||||
and "--approval" not in normalized.args
|
||||
and "approval" not in normalized.flags
|
||||
):
|
||||
normalized.args.append("--approval")
|
||||
|
||||
final_cmd = normalized.to_string()
|
||||
|
||||
# Return as code block for easy copy-paste (or auto-execution UI cues)
|
||||
if force_approval_policy:
|
||||
return CommandResponse(
|
||||
text=(
|
||||
f"```\n{final_cmd}\n```\n"
|
||||
f"(Policy: {self._policy_kv(decision.to_contract())})"
|
||||
)
|
||||
)
|
||||
return CommandResponse(text=f"```\n{final_cmd}\n```")
|
||||
|
||||
@staticmethod
|
||||
def _policy_kv(contract: Dict[str, Any]) -> str:
|
||||
ordered = ("code", "severity", "action", "reason")
|
||||
parts = []
|
||||
for key in ordered:
|
||||
value = contract.get(key)
|
||||
if value is not None:
|
||||
parts.append(f"{key}={value}")
|
||||
return "[" + ", ".join(parts) + "]"
|
||||
|
||||
async def _chat_template(self, llm: LLMClient, request: str) -> CommandResponse:
|
||||
"""Generate a template JSON suggestion."""
|
||||
if not request:
|
||||
return CommandResponse(text="Usage: /chat template <description>")
|
||||
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
|
||||
user_prompt = f"""Generate a workflow template JSON for this request:
|
||||
|
||||
Request: {request}
|
||||
|
||||
Output:
|
||||
1. Suggested filename
|
||||
2. Template JSON in a code block
|
||||
|
||||
Keep it minimal."""
|
||||
|
||||
response = await llm.chat(system_prompt, user_prompt)
|
||||
return CommandResponse(text=response)
|
||||
|
||||
async def _chat_status(self, llm: LLMClient) -> CommandResponse:
|
||||
"""Summarize system status using LLM."""
|
||||
# Fetch status data
|
||||
health = await self.client.get_health()
|
||||
queue = await self.client.get_prompt_queue()
|
||||
|
||||
status_data = {
|
||||
"health": health.get("data", {}) if health.get("ok") else "unavailable",
|
||||
"jobs": "admin-only; use /jobs as an authorized operator",
|
||||
"queue": queue.get("data", {}) if queue.get("ok") else "unavailable",
|
||||
}
|
||||
|
||||
system_prompt = CHAT_SYSTEM_PROMPT.format(trust_level="N/A")
|
||||
user_prompt = CHAT_STATUS_PROMPT.format(status_data=status_data)
|
||||
|
||||
response = await llm.chat(system_prompt, user_prompt)
|
||||
return CommandResponse(text=response)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Owned command parsing, dispatch, and authorization mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035, UP045 -- preserve the frozen public annotations.
|
||||
# mypy: disable-error-code="attr-defined,no-any-return"
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .config import CommandClass
|
||||
from .contract import CommandRequest, CommandResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouterRequestContext:
|
||||
"""Immutable dispatch values for one authorized command attempt."""
|
||||
|
||||
request: CommandRequest
|
||||
parsed_command: str
|
||||
canonical_command: str
|
||||
args: tuple[str, ...]
|
||||
command_class: CommandClass
|
||||
|
||||
|
||||
class RouterDispatchMixin:
|
||||
async def handle(self, req: CommandRequest) -> CommandResponse:
|
||||
"""Main dispatch loop."""
|
||||
text = req.text.strip()
|
||||
# NOTE: Debug-only raw message logging for troubleshooting parsing issues.
|
||||
# Enable with OPENCLAW_CONNECTOR_DEBUG=1. May include sensitive user content.
|
||||
if self.config.debug:
|
||||
logger.info(
|
||||
"DEBUG raw message: platform=%s user=%s chat=%s text=%r",
|
||||
req.platform,
|
||||
req.sender_id,
|
||||
req.channel_id,
|
||||
text,
|
||||
)
|
||||
|
||||
# F32 WP2: Rate limiting
|
||||
if not self._rate_limiter.is_allowed(str(req.sender_id), str(req.channel_id)):
|
||||
return CommandResponse(
|
||||
text="[Rate Limited] Too many requests. Please wait a moment."
|
||||
)
|
||||
|
||||
# F32 WP5: Command length limit
|
||||
if len(text) > self.config.max_command_length:
|
||||
return CommandResponse(
|
||||
text=f"[Error] Command too long ({len(text)} chars). Max: {self.config.max_command_length}."
|
||||
)
|
||||
|
||||
try:
|
||||
# IMPORTANT (recurring usability bug):
|
||||
# Do not use `shlex.split()` directly for ChatOps commands that may include natural
|
||||
# language. In POSIX mode, `shlex` treats apostrophes (`'`) as quote delimiters, so
|
||||
# common contractions like "She's" trigger "unbalanced quotes" failures.
|
||||
#
|
||||
# We therefore only treat *double quotes* (`"`) as quoting characters, so users can
|
||||
# still do: positive_prompt="a prompt with spaces" while apostrophes remain safe.
|
||||
lexer = shlex.shlex(text, posix=True)
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
lexer.quotes = '"'
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
return CommandResponse(
|
||||
text="[Error] Parsing command arguments failed (unbalanced quotes?)."
|
||||
)
|
||||
|
||||
if not parts:
|
||||
return CommandResponse(text="Empty command.")
|
||||
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1:]
|
||||
|
||||
# Telegram group commands often include the bot username suffix, e.g. `/help@mybot`.
|
||||
# If we don't strip it, the command won't match our dispatch table and appears "dead"
|
||||
# even though polling is working.
|
||||
if (
|
||||
(req.platform or "").lower() == "telegram"
|
||||
and cmd.startswith("/")
|
||||
and "@" in cmd
|
||||
):
|
||||
cmd = cmd.split("@", 1)[0]
|
||||
|
||||
# Some users type `@bot /help` in group chats. Treat that as a command too.
|
||||
if cmd.startswith("@") and args and args[0].startswith("/"):
|
||||
cmd = args[0].lower()
|
||||
args = args[1:]
|
||||
|
||||
# Dispatch Table
|
||||
handlers = {
|
||||
("/status", "status"): (self._handle_status, CommandClass.PUBLIC),
|
||||
("/help", "help", "/start"): (self._handle_help, CommandClass.PUBLIC),
|
||||
("/run", "run"): (self._handle_run, CommandClass.RUN),
|
||||
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
|
||||
self._handle_interrupt,
|
||||
CommandClass.ADMIN,
|
||||
), # Global interrupt => admin-only.
|
||||
("/approvals", "approvals"): (
|
||||
self._handle_approvals_list,
|
||||
CommandClass.ADMIN,
|
||||
),
|
||||
("/approve", "approve"): (self._handle_approve, CommandClass.ADMIN),
|
||||
("/reject", "reject"): (self._handle_reject, CommandClass.ADMIN),
|
||||
("/schedules", "schedules"): (
|
||||
self._handle_schedules_list,
|
||||
CommandClass.ADMIN,
|
||||
),
|
||||
("/schedule", "schedule"): (
|
||||
self._handle_schedule_subcommand,
|
||||
CommandClass.ADMIN,
|
||||
),
|
||||
# Phase 3 Introspection
|
||||
("/history", "history"): (self._handle_history, CommandClass.PUBLIC),
|
||||
("/trace", "trace"): (self._handle_trace, CommandClass.ADMIN), # Admin only
|
||||
("/jobs", "jobs", "queue"): (self._handle_jobs, CommandClass.ADMIN),
|
||||
# F30: Chat Assistant
|
||||
("/chat", "chat"): (self._handle_chat, CommandClass.PUBLIC),
|
||||
}
|
||||
|
||||
# Find Handler
|
||||
handler = None
|
||||
|
||||
canonical_cmd = cmd # Fallback
|
||||
for aliases, (func, cmd_class) in handlers.items():
|
||||
if cmd in aliases:
|
||||
handler = func
|
||||
default_class = cmd_class
|
||||
# R80 Remediation: Use canonical command (first alias) for policy checks
|
||||
# This prevents "run" vs "/run" bypass issues.
|
||||
# Convention: first alias is canonical (e.g. "/run").
|
||||
canonical_cmd = aliases[0] if isinstance(aliases, tuple) else aliases
|
||||
break
|
||||
|
||||
if not handler:
|
||||
return CommandResponse(
|
||||
text=f"Unknown command: {cmd}. Type /help for options."
|
||||
)
|
||||
|
||||
context = RouterRequestContext(
|
||||
request=req,
|
||||
parsed_command=cmd,
|
||||
canonical_command=canonical_cmd,
|
||||
args=tuple(args),
|
||||
command_class=default_class,
|
||||
)
|
||||
|
||||
# R80: Centralized Authorization Gate
|
||||
# Pass canonical_cmd to ensure policy matches aliases correctly
|
||||
if auth_err := self._check_command_authz(
|
||||
context.canonical_command, context.request, context.command_class
|
||||
):
|
||||
return auth_err
|
||||
|
||||
# Execute
|
||||
try:
|
||||
return await handler(context.request, list(context.args))
|
||||
except Exception as e:
|
||||
logger.exception(f"Command execution error {cmd}: {e}")
|
||||
return CommandResponse(text=f"[Internal Error] {e!s}")
|
||||
|
||||
def _is_admin(self, user_id: str) -> bool:
|
||||
return str(user_id) in self.config.admin_users
|
||||
|
||||
def _delivery_context(self, req: CommandRequest) -> Dict[str, Any]:
|
||||
context: Dict[str, Any] = {}
|
||||
if getattr(req, "workspace_id", ""):
|
||||
context["workspace_id"] = str(req.workspace_id)
|
||||
if getattr(req, "thread_id", ""):
|
||||
context["thread_id"] = str(req.thread_id)
|
||||
return context
|
||||
|
||||
def _check_command_authz(
|
||||
self, cmd: str, req: CommandRequest, default_class: CommandClass
|
||||
) -> Optional[CommandResponse]:
|
||||
"""
|
||||
R80: Verify command authorization policy.
|
||||
Returns None if allowed, or CommandResponse(text=error) if denied.
|
||||
"""
|
||||
policy = self.config.command_policy
|
||||
|
||||
# 1. Resolve Effective Class (Handle per-command overrides)
|
||||
# Note: 'cmd' here is the canonical parsed command string (lowercase), e.g., "/run" or "run"
|
||||
# The overrides dict might use "/run" or "run", we should check both or normalize.
|
||||
# Currently, the router logic normalized `cmd` from input (lines 90-101).
|
||||
# We'll check exact match against the override key.
|
||||
eff_class = policy.command_overrides.get(cmd, default_class)
|
||||
|
||||
# 2. Check AllowFrom List (Explicit User Allow)
|
||||
# If an explicit AllowFrom list exists for this class, the user MUST be in it.
|
||||
# This takes precedence over role logic.
|
||||
allowed_users = policy.allow_from.get(eff_class)
|
||||
if allowed_users is not None and len(allowed_users) > 0:
|
||||
if str(req.sender_id) not in allowed_users:
|
||||
# If explicit allow-list is active, even admins must be in it?
|
||||
# Decision: YES, for strict compliance. If you want admins, add them to the list.
|
||||
# However, for usability, usually admins are implied.
|
||||
# Let's stick to "Explicit List Wins" for R80 strict mode.
|
||||
return CommandResponse(
|
||||
text="[Access Denied] You are not in the allow-list for this command."
|
||||
)
|
||||
# If in list, proceed (bypass default role checks? No, usually allows)
|
||||
return None
|
||||
|
||||
# 3. Default Role Logic
|
||||
if eff_class == CommandClass.ADMIN and not self._is_admin(req.sender_id):
|
||||
return CommandResponse(
|
||||
text="[Access Denied] This command requires Admin privileges."
|
||||
)
|
||||
|
||||
# PUBLIC and RUN are allowed by default (RUN checks trust internally)
|
||||
return None
|
||||
|
||||
def _is_trusted(self, req: CommandRequest) -> bool:
|
||||
"""
|
||||
Trusted users can execute /run immediately.
|
||||
Untrusted users are routed to approval flow.
|
||||
"""
|
||||
if self._is_admin(req.sender_id):
|
||||
return True
|
||||
|
||||
platform = (req.platform or "").lower()
|
||||
sender_id = str(req.sender_id)
|
||||
channel_id = str(req.channel_id)
|
||||
|
||||
if platform == "telegram":
|
||||
try:
|
||||
uid = int(sender_id)
|
||||
except ValueError:
|
||||
uid = None
|
||||
try:
|
||||
cid = int(channel_id)
|
||||
except ValueError:
|
||||
cid = None
|
||||
if uid is not None and uid in self.config.telegram_allowed_users:
|
||||
return True
|
||||
return cid is not None and cid in self.config.telegram_allowed_chats
|
||||
|
||||
if platform == "discord":
|
||||
if sender_id in self.config.discord_allowed_users:
|
||||
return True
|
||||
return channel_id in self.config.discord_allowed_channels
|
||||
|
||||
if platform == "line":
|
||||
if sender_id in self.config.line_allowed_users:
|
||||
return True
|
||||
return channel_id in self.config.line_allowed_groups
|
||||
|
||||
if platform == "whatsapp":
|
||||
return sender_id in self.config.whatsapp_allowed_users
|
||||
|
||||
if platform == "wechat":
|
||||
return sender_id in self.config.wechat_allowed_users
|
||||
|
||||
if platform == "kakao":
|
||||
return sender_id in self.config.kakao_allowed_users
|
||||
|
||||
if platform == "slack":
|
||||
if sender_id in self.config.slack_allowed_users:
|
||||
return True
|
||||
return channel_id in self.config.slack_allowed_channels
|
||||
|
||||
if platform == "feishu":
|
||||
if sender_id in self.config.feishu_allowed_users:
|
||||
return True
|
||||
return channel_id in self.config.feishu_allowed_chats
|
||||
|
||||
# Unknown platform: trust only admins
|
||||
return False
|
||||
|
||||
# --- Handlers ---
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Owned run and interrupt command-family mixin."""
|
||||
|
||||
# ruff: noqa: UP006, UP035 -- preserve the frozen public annotations.
|
||||
# mypy: disable-error-code="attr-defined,no-any-return"
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .contract import CommandRequest, CommandResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouterExecutionMixin:
|
||||
async def _handle_run(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
if not args:
|
||||
return CommandResponse(
|
||||
text="Usage: /run <template_id> [prompt text] [key=value ...] [--approval]"
|
||||
)
|
||||
|
||||
# Parse flags
|
||||
explicit_approval = False
|
||||
clean_args = []
|
||||
for arg in args:
|
||||
if arg in ("--require-approval", "--approval", "-a"):
|
||||
explicit_approval = True
|
||||
else:
|
||||
clean_args.append(arg)
|
||||
|
||||
if not clean_args:
|
||||
return CommandResponse(text="Usage: /run <template_id> ...")
|
||||
|
||||
template_id = clean_args[0]
|
||||
inputs: Dict[str, str] = {}
|
||||
free_text_parts: List[str] = []
|
||||
for arg in clean_args[1:]:
|
||||
if "=" in arg:
|
||||
k, v = arg.split("=", 1)
|
||||
inputs[k.strip()] = v.strip()
|
||||
else:
|
||||
free_text_parts.append(arg)
|
||||
|
||||
# If user provided free text without key=value, treat it as the prompt.
|
||||
# We map it to a best-effort prompt key (prefers template metadata if available).
|
||||
if free_text_parts:
|
||||
prompt_key = await self._resolve_prompt_key(template_id)
|
||||
if prompt_key not in inputs:
|
||||
inputs[prompt_key] = " ".join(free_text_parts).strip()
|
||||
elif self.config.debug:
|
||||
logger.info(
|
||||
"DEBUG /run free-text ignored (prompt key already set): %s",
|
||||
prompt_key,
|
||||
)
|
||||
|
||||
# NOTE: Debug-only payload logging for troubleshooting prompt mismatches.
|
||||
# Enable with OPENCLAW_CONNECTOR_DEBUG=1 to log template_id + inputs.
|
||||
if self.config.debug:
|
||||
logger.info(
|
||||
"DEBUG /run payload: template=%s inputs=%s approval_flag=%s trusted=%s",
|
||||
template_id,
|
||||
inputs,
|
||||
explicit_approval,
|
||||
self._is_trusted(req),
|
||||
)
|
||||
|
||||
trusted = self._is_trusted(req)
|
||||
require_approval = explicit_approval or (not trusted)
|
||||
|
||||
res = await self.client.submit_job(
|
||||
template_id, inputs, require_approval=require_approval
|
||||
)
|
||||
if res.get("ok"):
|
||||
data = res.get("data", {})
|
||||
trace_id = data.get("trace_id", "unknown")
|
||||
|
||||
if data.get("pending"):
|
||||
approval_id = data.get("approval_id", "unknown")
|
||||
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
|
||||
if "expires_at" in data:
|
||||
msg += f"\nExpires: {data['expires_at']}"
|
||||
if self.poller:
|
||||
# IMPORTANT:
|
||||
# For untrusted users, approvals are done in the OpenClaw UI.
|
||||
# We must start tracking the approval_id so we can map
|
||||
# approval_id -> executed_prompt_id later and auto-deliver images.
|
||||
self.poller.track_approval(
|
||||
approval_id,
|
||||
req.platform,
|
||||
req.channel_id,
|
||||
req.sender_id,
|
||||
delivery_context=self._delivery_context(req),
|
||||
)
|
||||
return CommandResponse(text=msg)
|
||||
else:
|
||||
prompt_id = data.get("prompt_id", "unknown")
|
||||
if self.poller:
|
||||
self.poller.track_job(
|
||||
prompt_id,
|
||||
req.platform,
|
||||
req.channel_id,
|
||||
req.sender_id,
|
||||
delivery_context=self._delivery_context(req),
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
|
||||
)
|
||||
else:
|
||||
err = res.get("error", "Unknown error")
|
||||
return CommandResponse(text=f"[Submission Failed] Reason: {err}")
|
||||
|
||||
async def _resolve_prompt_key(self, template_id: str) -> str:
|
||||
"""
|
||||
Best-effort prompt key resolution.
|
||||
Prefer template metadata (allowed_inputs), then fall back to common names.
|
||||
"""
|
||||
meta = await self._get_template_meta(template_id)
|
||||
allowed = meta.get("allowed_inputs") or []
|
||||
|
||||
# If template explicitly declares a single input, use it.
|
||||
if isinstance(allowed, list) and len(allowed) == 1:
|
||||
return str(allowed[0])
|
||||
|
||||
preferred = ("positive_prompt", "prompt", "text", "positive", "caption")
|
||||
if isinstance(allowed, list):
|
||||
for key in preferred:
|
||||
if key in allowed:
|
||||
return key
|
||||
|
||||
# Default fallback
|
||||
return "positive_prompt"
|
||||
|
||||
async def _get_template_meta(self, template_id: str) -> Dict[str, Any]:
|
||||
if template_id in self._template_meta_cache:
|
||||
return self._template_meta_cache[template_id]
|
||||
try:
|
||||
res = await self.client.get_templates()
|
||||
if res.get("ok"):
|
||||
for item in res.get("templates", []) or []:
|
||||
if item.get("id") == template_id:
|
||||
self._template_meta_cache[template_id] = item
|
||||
return item
|
||||
except Exception as e:
|
||||
if self.config.debug:
|
||||
logger.info(f"DEBUG template meta fetch failed: {e}")
|
||||
return {}
|
||||
|
||||
async def _handle_interrupt(
|
||||
self, req: CommandRequest, args: List[str]
|
||||
) -> CommandResponse:
|
||||
# F32 WP3: Guard
|
||||
if err := self._require_admin_token_configured():
|
||||
return err
|
||||
|
||||
targets = self._parse_stop_targets(args)
|
||||
if not targets:
|
||||
res = await self.client.interrupt_output()
|
||||
if res.get("ok"):
|
||||
return CommandResponse(text="[Stop] Global Interrupt sent to ComfyUI.")
|
||||
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
|
||||
|
||||
if len(targets) == 1:
|
||||
job_id = targets[0]
|
||||
res = await self.client.cancel_job(job_id)
|
||||
if res.get("ok"):
|
||||
return CommandResponse(
|
||||
text=f"[Stop] Cancellation requested for job {job_id}."
|
||||
)
|
||||
|
||||
# IMPORTANT: Targeted stops must never degrade to no-payload global
|
||||
# interrupt. Older-host fallback is allowed only with prompt_id set.
|
||||
if self._jobs_cancel_unsupported(res):
|
||||
fallback = await self.client.interrupt_output(prompt_id=job_id)
|
||||
if fallback.get("ok"):
|
||||
return CommandResponse(
|
||||
text=(
|
||||
f"[Stop] Targeted interrupt sent for job {job_id} "
|
||||
"(jobs cancel unsupported)."
|
||||
)
|
||||
)
|
||||
return CommandResponse(text=f"[Stop Failed] {fallback.get('error')}")
|
||||
|
||||
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
|
||||
|
||||
res = await self.client.cancel_jobs(targets)
|
||||
if res.get("ok"):
|
||||
return CommandResponse(
|
||||
text=f"[Stop] Cancellation requested for {len(targets)} jobs."
|
||||
)
|
||||
return CommandResponse(text=f"[Stop Failed] {res.get('error')}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_stop_targets(args: List[str]) -> List[str]:
|
||||
targets: List[str] = []
|
||||
for arg in args:
|
||||
for part in str(arg).split(","):
|
||||
target = part.strip()
|
||||
if target:
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
def _jobs_cancel_unsupported(res: Dict[str, Any]) -> bool:
|
||||
status = res.get("status")
|
||||
if status in (404, 405, 501):
|
||||
return True
|
||||
error = str(res.get("error", "")).lower()
|
||||
unsupported_markers = (
|
||||
"404",
|
||||
"not found",
|
||||
"method not allowed",
|
||||
"unsupported",
|
||||
"not implemented",
|
||||
)
|
||||
return any(marker in error for marker in unsupported_markers)
|
||||
+18
-14
@@ -39,24 +39,28 @@ If the deployment enables remote control or bridge features, it must also pass *
|
||||
|
||||
### 3. Validation (Must Pass)
|
||||
|
||||
Run the full regression suite:
|
||||
Run the complete OS-specific regression gate:
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
powershell -File scripts/run_full_tests_windows.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# 1. Secret Scanning
|
||||
./.venv/Scripts/python.exe -m pre_commit run detect-secrets --all-files
|
||||
|
||||
# 2. Lint & Formatting
|
||||
./.venv/Scripts/python.exe -m pre_commit run --all-files --show-diff-on-failure
|
||||
|
||||
# 3. Backend Unit Coverage Gate
|
||||
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" ./.venv/Scripts/python.exe scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json
|
||||
|
||||
# 4. Frontend E2E (Unit/Integration)
|
||||
# Ensure Node 18+
|
||||
node -v
|
||||
npm test
|
||||
# Linux / WSL
|
||||
bash scripts/run_full_tests_linux.sh
|
||||
```
|
||||
|
||||
These scripts execute the authoritative `tests/TEST_SOP.md` sequence, including fresh
|
||||
lockfile reconciliation with `npm ci`, the blocking
|
||||
`npm audit --audit-level=high` check across production and development dependencies,
|
||||
secret scanning, pre-commit hooks, governance and backend lanes, adaptive adversarial
|
||||
validation, and frontend Playwright E2E. A standalone `npm test` result is not a
|
||||
substitute for the complete release gate.
|
||||
|
||||
If staged/manual execution is required, follow the explicit command order in
|
||||
`tests/TEST_SOP.md`; do not maintain a shortened release-only sequence here.
|
||||
|
||||
---
|
||||
|
||||
## Gate B: Bridge / Remote Control Safety (Conditional)
|
||||
|
||||
+4
-4
@@ -12,10 +12,10 @@
|
||||
|
||||
Only the latest version of ComfyUI-OpenClaw is supported for security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| Latest | :white_check_mark: |
|
||||
| < 0.2.0 | :x: |
|
||||
| Version | Supported |
|
||||
| ------------------------ | ------------------ |
|
||||
| Latest published release | :white_check_mark: |
|
||||
| All earlier releases | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Service Domain Packages
|
||||
|
||||
Bootstrap lifecycle, route registration, and effective security posture have explicit
|
||||
implementation owners:
|
||||
|
||||
- `services/bootstrap/lifecycle.py` owns startup phase, outcome, and optional-warmup state.
|
||||
- `services/bootstrap/registration.py` owns host route registration and retry coordination.
|
||||
- `services/posture/effective.py` owns the immutable process security-posture snapshot.
|
||||
|
||||
The historical modules remain compatibility aliases:
|
||||
|
||||
- `services/startup_lifecycle.py`
|
||||
- `services/route_bootstrap.py`
|
||||
- `services/effective_security_posture.py`
|
||||
|
||||
Each alias maps its module name to the implementation module object. This preserves one
|
||||
process singleton and keeps existing imports and patch points compatible. Do not replace
|
||||
these aliases with copied re-exports: copied module globals can diverge from the state used
|
||||
by implementation functions. Type-checker-only exports may describe the legacy interface,
|
||||
but they must stay behind `TYPE_CHECKING` and must not become a second runtime owner.
|
||||
|
||||
New implementation code should import the domain-owned modules. Existing consumers may
|
||||
continue to use the compatibility paths. An implementation module must never import its
|
||||
compatibility alias; the repository dependency policy enforces that direction.
|
||||
|
||||
Package initializers are navigation-only. They must not register routes, resolve posture,
|
||||
start threads, or re-export mutable process state during import.
|
||||
@@ -1,8 +1,8 @@
|
||||
# ComfyUI Asset API Adoption Decision (2026-04-16)
|
||||
|
||||
## 2026-07-10 reference anchor update
|
||||
## 2026-07-31 reference anchor update
|
||||
|
||||
- Current reference anchor is ComfyUI `1377a2f7` (`v0.27.0-47-g1377a2f7`, pyproject `0.27.0`).
|
||||
- Current reference anchor is ComfyUI `9cf91339` (`v0.29.0-12-g9cf91339`, pyproject `0.29.0`).
|
||||
- SaveImage output sockets, 3D preview refs, typed asset dimensions, grouped asset downloads, and optional `hash` / `asset_hash` aliases do not change the no-go decision.
|
||||
- ComfyUI asset hashing is host-side opt-in through `--enable-asset-hashing`, so normal filename-backed output refs must not require hash metadata.
|
||||
- Current host asset metadata may expose `loader_path`; model uploads require `model_type:<folder_name>` tags, and `/features.supports_model_type_tags` advertises that contract. OpenClaw does not upload through or directly consume `/api/assets`, so these facts do not change the no-go decision.
|
||||
@@ -33,7 +33,7 @@
|
||||
- optional asset-hash-backed refs that still resolve through `/view?filename=blake3:...` when host metadata is present
|
||||
- media-aware output groups (`images`, `video`, `audio`, `3d`, and bounded `text`)
|
||||
- HDR `.exr` / `.hdr` image refs as explicit `/view` source-preview fallback links, not normal thumbnails
|
||||
- Current ComfyUI `1377a2f7` / `v0.27.0-47-g1377a2f7` / pyproject `0.27.0` reference facts:
|
||||
- Current ComfyUI `9cf91339` / `v0.29.0-12-g9cf91339` / pyproject `0.29.0` reference facts:
|
||||
- `/api/assets*` routes exist, but operational use is feature-gated behind `--enable-assets`
|
||||
- content hashing is opt-in through `--enable-asset-hashing`, so normal filename-backed refs may omit `asset_hash` / `hash`
|
||||
- `/features` exposes the `assets` capability flag so hosts can report whether the asset system is enabled
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# /etc/default/openclaw.env
|
||||
# Secure environment configuration for OpenClaw
|
||||
|
||||
# Admin Token (Required for remote ops)
|
||||
OPENCLAW_ADMIN_TOKEN=change-me-to-a-strong-secret
|
||||
|
||||
# Observability Token (Required for remote logs)
|
||||
# (Legacy: MOLTBOT_OBSERVABILITY_TOKEN)
|
||||
OPENCLAW_OBSERVABILITY_TOKEN=change-me-too
|
||||
|
||||
# Bridge (Default: 0/Disabled)
|
||||
OPENCLAW_BRIDGE_ENABLED=0
|
||||
# OPENCLAW_BRIDGE_DEVICE_TOKEN=
|
||||
|
||||
# Optional startup log hygiene (truncate openclaw.log once per process start)
|
||||
# OPENCLAW_LOG_TRUNCATE_ON_START=1
|
||||
|
||||
# Network
|
||||
# Bind to localhost by default
|
||||
COMFYUI_LISTEN=127.0.0.1
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copy this public template to /etc/default/openclaw.env before starting the service.
|
||||
# Replace every placeholder locally; never commit the deployed environment file.
|
||||
|
||||
# Admin Token (required for remote operations)
|
||||
OPENCLAW_ADMIN_TOKEN=replace-with-a-strong-secret
|
||||
|
||||
# Observability Token (required for remote logs)
|
||||
# Legacy name: MOLTBOT_OBSERVABILITY_TOKEN
|
||||
OPENCLAW_OBSERVABILITY_TOKEN=replace-with-an-observability-secret
|
||||
|
||||
# Bridge (default: disabled)
|
||||
OPENCLAW_BRIDGE_ENABLED=0
|
||||
# OPENCLAW_BRIDGE_DEVICE_TOKEN=
|
||||
|
||||
# Optional startup log hygiene (truncate openclaw.log once per process start)
|
||||
# OPENCLAW_LOG_TRUNCATE_ON_START=1
|
||||
|
||||
# Bind to localhost by default
|
||||
COMFYUI_LISTEN=127.0.0.1
|
||||
@@ -5,10 +5,12 @@ OpenClaw's frontend uses modular vanilla ES modules loaded by the ComfyUI extens
|
||||
## Tab Registration
|
||||
|
||||
- Register the OpenClaw host sidebar entry through `registerOpenClawSidebar(app, tabDefinition)` from `web/openclaw_sidebar_registration.js`; it prefers ComfyUI's current sidebar store API and falls back to the deprecated frontend facade for older host bundles.
|
||||
- Register tabs through `tabManager.registerTab({ id, title, icon, render })`.
|
||||
- Register tabs through `tabManager.registerTab({ id, title, icon, render, dispose? })`.
|
||||
- Keep `id` stable; it is used for pane ids and active-tab storage.
|
||||
- Treat `render(pane)` as the only place that mutates a tab pane.
|
||||
- Return a promise from `render` only when the tab genuinely performs async work; async failures are routed through the tab error boundary.
|
||||
- Use optional `dispose(pane)` for pending-render cleanup. Returning `true` requests a fresh render
|
||||
when the user revisits the tab; completed panes should remain reusable.
|
||||
|
||||
## DOM Helpers
|
||||
|
||||
@@ -18,14 +20,19 @@ OpenClaw's frontend uses modular vanilla ES modules loaded by the ComfyUI extens
|
||||
- `queryRequired(...)` when a selector is mandatory for the tab to function.
|
||||
- Use `textContent` semantics for user-visible text. Do not add raw HTML helper paths for convenience.
|
||||
- Keep legacy class aliasing centralized through existing normalization and alias helpers.
|
||||
- Keep Settings-specific status, LLM, secrets, logs, DOM, and lifecycle behavior in the focused
|
||||
`web/tabs/settings_tab_*.js` owners rather than rebuilding a monolithic renderer.
|
||||
|
||||
## API Contracts
|
||||
|
||||
- Use `OpenClawAPI.fetch(...)` normalized results instead of direct `fetch` from tabs.
|
||||
- Check `result.ok` before reading `result.data`.
|
||||
- Preserve admin-token handling inside `OpenClawAPI` and shared session helpers.
|
||||
- Add endpoint methods to the matching config, generation, resource, model, or event owner module;
|
||||
preserve `web/openclaw_api.js` as the transport/session facade and keep one shared singleton.
|
||||
|
||||
## Verification
|
||||
|
||||
- Add Vitest coverage for new shared helpers or tab wiring behavior.
|
||||
- Use Playwright harness specs for user-visible tab behavior such as active panes, rendered content, and action outcomes.
|
||||
- For async tabs, cover switching/disposal and prove stale completion cannot mutate the new pane.
|
||||
|
||||
@@ -11,9 +11,18 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
|
||||
- Event/task polling: admin-console and model/task views consume deterministic delta metadata (`effective_since_seq`, `next_since_seq`, reset/truncation hints) instead of assuming every refresh is a full snapshot.
|
||||
- Notification center: `web/openclaw_notification_center.js` owns persistent in-app notification storage, dedupe, acknowledge, dismiss, and deep-link behavior.
|
||||
- Banner runtime: `web/openclaw_banner_manager.js` owns transient banner state and shell-facing banner transitions.
|
||||
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, and remount safety.
|
||||
- API: `web/openclaw_api.js` provides a normalized fetch wrapper and OpenClaw endpoints (legacy Moltbot endpoints still work).
|
||||
- Host surface: `web/openclaw_host_surface.js` resolves the active frontend host surface and stamps explicit metadata so standalone frontend vs desktop-embedded behavior stays testable.
|
||||
- Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, remount safety, and optional
|
||||
pending-render disposal before switching panes.
|
||||
- API: `web/openclaw_api.js` owns normalized transport, session, timeout, retry, and singleton
|
||||
behavior. Config, generation, resource, model, and event endpoint families live in focused
|
||||
`web/openclaw_api_*.js` owner modules behind the same public API (legacy Moltbot endpoints still
|
||||
work).
|
||||
- Settings: `web/tabs/settings_tab.js` composes status, LLM, secrets, logs, and DOM owner modules.
|
||||
Its lifecycle owner invalidates stale async generations and clears scheduled work when the tab
|
||||
is disposed, preventing late responses from mutating a remounted pane.
|
||||
- Host surface: `web/openclaw_host_surface.js` resolves standalone frontend, legacy fixed-bundle
|
||||
Desktop, and current managed-install Comfy-Desktop separately, then stamps explicit metadata so
|
||||
generation-specific behavior stays testable.
|
||||
- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs, optional `asset_hash`/`hash` refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`) onto one media-aware contract. Allowlisted text files under the host `files` key stay on same-origin `/view` and use a 5-second, 64-KiB streaming, strict textual-MIME/UTF-8 reader with a 4,096-character display cap. HDR `.exr` / `.hdr` image refs show source-preview fallback links instead of normal thumbnails, text reaches the DOM only as literal text, and asset-service-only refs remain explicit fallback states instead of silently auto-fetching `/api/assets`.
|
||||
- Styles: `web/openclaw.css` provides shared design tokens and component classes.
|
||||
- Errors and compatibility helpers: `web/openclaw_utils.js` provides `showError()` / `clearError()` plus runtime legacy-class alias helpers used to keep canonical `openclaw-*` markup compatible with existing `moltbot-*` selectors.
|
||||
@@ -21,10 +30,17 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve
|
||||
Refactor note:
|
||||
- `web/openclaw_ui.js` should stay focused on shell composition, shared singleton ownership, and exports.
|
||||
- New shell behaviors should prefer the extracted action/queue modules unless they truly belong to top-level shell assembly.
|
||||
- New API methods should be added to the matching route-family owner rather than growing the
|
||||
transport facade; keep `openclawApi` as the only shared singleton.
|
||||
- New Settings behavior should stay in the matching status/LLM/secrets/logs/DOM owner and use the
|
||||
shared generation lifecycle for delayed or asynchronous UI changes.
|
||||
- New tab markup should use canonical `openclaw-*` classes; legacy `moltbot-*` aliases are generated centrally at runtime instead of being duplicated in each template.
|
||||
- New host sidebar registration changes should stay in `web/openclaw_sidebar_registration.js` rather than duplicating ComfyUI frontend API detection inside the extension entrypoint.
|
||||
- Host-sensitive behaviors should consume the shared host-surface helper rather than inferring desktop vs standalone frontend from ad-hoc globals.
|
||||
- Graph/widget flows should preserve host-shaped promoted-widget source metadata and non-numeric node IDs, including Parameter Lab replay/apply paths.
|
||||
- Parameter Lab flows should keep scalar/count/byte validation aligned with the backend policy and
|
||||
use exact request-ID queue receipts; they must not infer prompt ownership from a globally recent
|
||||
prompt when the host request boundary is unsupported or ambiguous.
|
||||
- Output preview flows should consume the shared asset-ref normalizer rather than assembling `/view` URLs independently in each tab, treating non-image or HDR media as broken images, or silently widening runtime behavior to direct `/api/assets` fetches.
|
||||
- Explorer/preflight consumers should treat inventory diagnostics as snapshot-first and surface `snapshot_ts`, `scan_state`, `stale`, and `last_error` instead of blocking the UI on full rescans.
|
||||
- Explorer/preflight rendering should keep actionable missing-node/model failures separate from suppressed inactive-branch findings returned by the backend.
|
||||
@@ -45,9 +61,15 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
|
||||
|
||||
## Host-Surface Contract
|
||||
|
||||
- OpenClaw treats standalone `ComfyUI_frontend` and `desktop` as distinct frontend host surfaces.
|
||||
- OpenClaw treats standalone `ComfyUI_frontend`, legacy fixed-bundle `desktop`, and current
|
||||
managed-install `comfy_desktop` as distinct frontend host surfaces.
|
||||
- The sidebar stamps its resolved host surface and refreshed host-reference metadata at mount time so desktop bundle drift is explicit in diagnostics and regression tests.
|
||||
- The standalone Remote Admin Console now stamps the same host-surface metadata on its document root, including desktop `0.9.4`, bundled core `0.22.3`, embedded frontend `1.43.18`, and lagging parity relative to standalone frontend `1.48.1`.
|
||||
- The standalone Remote Admin Console stamps the same host-surface metadata on its document root,
|
||||
including legacy Desktop `0.9.4`, fixed core `0.22.3`, embedded frontend `1.43.18`, and lagging
|
||||
parity relative to standalone frontend `1.49.1`. It also exposes current Comfy-Desktop
|
||||
`1.0.32-rc.1` with `installation_specific` hosted versions. Presence of
|
||||
`window.__comfyDesktop2` identifies that host generation only; it does not authorize privileged
|
||||
capability calls or inspect bridge members.
|
||||
- Graph/widget compatibility code should route through shared host helpers to keep nested-subgraph and promoted-widget behavior aligned with current upstream host semantics, including preserving source metadata and string-shaped node IDs.
|
||||
|
||||
## Standalone Remote Admin Console
|
||||
@@ -85,9 +107,12 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
|
||||
4. 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.
|
||||
5. 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.
|
||||
6. Jobs: verify output previews still resolve for classic history refs, optional hash-backed refs when host metadata is present, and supported media-aware refs (`images`, `video`, `audio`, `3d`, bounded inline/file-backed `text`); allowlisted text files should show literal bounded content or a deterministic source-link fallback, HDR `.exr` / `.hdr` image refs should render as explicit source-preview fallback links, asset-service-only refs should stay explicit as a bounded fallback state, and repeated polls should not duplicate rows after reconnect/resume.
|
||||
7. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues, and verify inactive-branch suppressed findings render separately from actionable failures.
|
||||
8. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
|
||||
9. 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.
|
||||
7. Parameter Lab: verify bounded scalar sweep/compare values queue with an exact request receipt,
|
||||
and verify unsupported structured values or unknown host queue-event shapes fail visibly without
|
||||
assigning another prompt's lifecycle.
|
||||
8. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues, and verify inactive-branch suppressed findings render separately from actionable failures.
|
||||
9. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes).
|
||||
10. 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
|
||||
|
||||
@@ -96,5 +121,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R
|
||||
- Harness: `tests/e2e/test-harness.html` (mocks ComfyUI core + basic OpenClaw API calls)
|
||||
- Harness bootstrap now retries one transient `openclaw.js` module-fetch failure before surfacing a hard load error, so CI-only first-request flakiness does not get misreported as a permanent sidebar failure.
|
||||
- Web helper/self-test harness: `web/tests/e2e-harness.html` (includes frontend helper and wrapper idempotence checks)
|
||||
- Frontend unit contracts also freeze API exports/signatures, singleton identity, Settings DOM
|
||||
identities, owner direction, and stale-generation disposal across the decomposed modules.
|
||||
- Desktop host parity lane: `tests/e2e/specs/desktop_host_parity.spec.js` verifies standalone vs desktop host evidence separately and covers both sidebar and Remote Admin host-sensitive behavior under the shared harness shims.
|
||||
- When investigating suspected harness flakes locally, prefer `npm run test:stress -- <spec>` so the same shared bootstrap path is exercised repeatedly without changing the default `npm test` contract.
|
||||
|
||||
@@ -181,7 +181,7 @@ Model-manager contract notes:
|
||||
- `/models/downloads` supports `since_seq` cursor polling and may return deterministic delta metadata (`requested_since_seq`, `effective_since_seq`, `next_since_seq`, truncation/reset hints) alongside the task list
|
||||
- `model_type` values SHOULD use current ComfyUI folder keys where applicable, including `text_encoders`, `diffusion_models`, `clip_vision`, `style_models`, `upscale_models`, `vae_approx`, `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `audio_encoders`, `background_removal`, `frame_interpolation`, `geometry_estimation`, `optical_flow`, and `detection`
|
||||
- legacy aliases such as `ckpt`, `checkpoints`, `loras`, `controlnets`, `clip`, `text_encoder`, `unet`, `diffusion_model`, `upscale_model`, `latent_upscale_model`, `hypernetwork`, `model_patch`, and `audio_encoder` are normalized before filtering or import destination resolution
|
||||
- current ComfyUI folder keys that are not managed model-file destinations fail closed for download creation: `configs` (configuration YAML), `diffusers` (folder-valued trees), `classifiers` (extensionless classifier artifacts), and `custom_nodes` (executable plugin code)
|
||||
- current ComfyUI folder keys that are not managed model-file destinations fail closed for download creation: `configs` (configuration YAML), `diffusers` (folder-valued trees), `classifiers` (extensionless classifier artifacts), `custom_nodes` (executable plugin code), and `datasets` (user-managed training data)
|
||||
- download creation requires structured provenance metadata (`publisher`, `license`, `source_url`) and a 64-char `expected_sha256`
|
||||
- import keeps fail-closed destination/filename validation and re-checks the staged file hash before activation
|
||||
|
||||
@@ -357,7 +357,7 @@ These limits are contractual and strictly enforced. Clients MUST handle `413` an
|
||||
| **Payload Size** | Rendered workflow | 512KB | `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` |
|
||||
| **Webhook Body** | Raw JSON body | 10MB | `MAX_BODY_SIZE` (internal constant) |
|
||||
| **Trigger Inputs** | Input variables | 32KB | Hardcoded in `api/triggers.py` |
|
||||
| **Log Tail** | Max lines | 500 | Hardcoded in `api/routes.py` |
|
||||
| **Log Tail** | Max lines | 500 | Hardcoded in `api/route_handlers.py` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ All pull requests must pass the repository SOP gate before merge.
|
||||
| --- | --- | --- |
|
||||
| Secret detection | `pre-commit run detect-secrets --all-files` | Prevent secret leakage |
|
||||
| Pre-commit hooks | `pre-commit run --all-files --show-diff-on-failure` | Enforce formatting and static checks |
|
||||
| Frontend dependency audit | `npm audit --production` | Fail on production dependency vulnerabilities in the shipped Node dependency surface |
|
||||
| Production dependency boundary | `python scripts/verify_production_dependencies.py` | Parse tracked production imports without importing modules; block ownership, direction, cycle, and dynamic-import drift |
|
||||
| Frontend dependency audit | `npm ci` then `npm audit --audit-level=high` | Reconcile the lockfile and fail on high/critical vulnerabilities across production and development dependencies |
|
||||
| Backend dependency audit | `pip-audit -r requirements.txt` | Audit declared Python project dependencies without scanning unrelated CI runner/toolchain packages |
|
||||
| GitHub CodeQL analysis | `.github/workflows/codeql.yml` | Run repository-native static security analysis for Python, JavaScript/TypeScript, and GitHub Actions on push, pull request, and weekly schedule |
|
||||
| Coverage governance | `python scripts/verify_quality_governance.py` | Fail closed on coverage-policy, mutation-threshold, SOP-guidance, and survivor-allowlist drift |
|
||||
@@ -39,7 +40,8 @@ If a change intentionally modifies contract behavior:
|
||||
|
||||
- Coverage governance is part of the standard gate, not an optional reporting step.
|
||||
- Dependency-audit governance is part of CI parity:
|
||||
- Node audit should continue to target production dependencies only.
|
||||
- Node audit must cover production and development dependencies because build and test tooling is part of the acceptance trust boundary.
|
||||
- A separate production-only audit may be retained as a runtime-boundary readback, but it is not a substitute for the full blocking audit.
|
||||
- Python audit must stay scoped to `requirements.txt`; env-wide bare `pip-audit` is out of contract because it can fail on tool-only transient packages that are not part of the repo dependency surface.
|
||||
- GitHub Actions workflow files are part of the security boundary:
|
||||
- workflows using `GITHUB_TOKEN` must declare explicit least-privilege `permissions:` instead of relying on repository defaults
|
||||
@@ -47,7 +49,7 @@ If a change intentionally modifies contract behavior:
|
||||
- CodeQL analysis must stay versioned in `.github/workflows/codeql.yml`; do not rely on UI-only default-setup drift for the repository baseline
|
||||
- CodeQL rollout remains visibility-first until the active backlog is burned down; treat new workflow findings as triage input, not an automatic merge blocker, unless the gating policy is explicitly tightened in roadmap/docs
|
||||
- `pyproject.toml` must keep:
|
||||
- `fail_under >= 45.0`
|
||||
- `fail_under >= 55.0`
|
||||
- `show_missing = true`
|
||||
- `skip_covered = true`
|
||||
- staged coverage ratchet policy (`tests/coverage_governance_policy.json`) is the source of truth for:
|
||||
@@ -59,6 +61,8 @@ If a change intentionally modifies contract behavior:
|
||||
- `python scripts/report_coverage_governance.py --coverage-json <path-to-coverage.json>`
|
||||
- release-cycle promotion evidence must be retained in:
|
||||
- `tests/coverage_promotion_reviews.json`
|
||||
- ratchet-55 reviews must contain consecutive release boundaries, immutable commit and
|
||||
full-suite artifact identity, every required hotspot percentage, and owned regression suites
|
||||
- backend coverage gate should use:
|
||||
- `python scripts/run_backend_coverage.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json --coverage-json .tmp/coverage/backend_unit_coverage.json`
|
||||
- Test debt governance remains fail-closed:
|
||||
|
||||
@@ -3,22 +3,39 @@
|
||||
```openclaw-compat-matrix-meta
|
||||
{
|
||||
"anchors": {
|
||||
"comfyui": "1377a2f7 (v0.27.0-47-g1377a2f7 / pyproject 0.27.0)",
|
||||
"comfyui_frontend": "1.48.1 (ceb5ae1eba / v1.48.1-1-gceb5ae1eba)",
|
||||
"comfy_desktop": "1.0.32-rc.1 (85e28b7a / v1.0.32-rc.1-3-g85e28b7)",
|
||||
"comfyui": "9cf91339 (v0.29.0-12-g9cf91339 / pyproject 0.29.0)",
|
||||
"comfyui_frontend": "1.49.1 (4b3866b838 / v1.49.1-19-g4b3866b838)",
|
||||
"desktop": "0.9.4 (core 0.22.3 / frontend 1.43.18)"
|
||||
},
|
||||
"evidence": {
|
||||
"evidence_id": "compat-matrix-refresh-20260710",
|
||||
"updated_at": "2026-07-10T15:50:42+00:00",
|
||||
"updated_by": "host-reference-refresh"
|
||||
"evidence_id": "compat-matrix-refresh-20260731",
|
||||
"updated_at": "2026-07-31T04:03:00+08:00",
|
||||
"updated_by": "host-reference-alignment"
|
||||
},
|
||||
"last_validated_date": "2026-07-10",
|
||||
"matrix_version": "v0.2.8",
|
||||
"host_surfaces": {
|
||||
"comfy_desktop": {
|
||||
"anchor_key": "comfy_desktop",
|
||||
"core_version": null,
|
||||
"frontend_version": null,
|
||||
"generation": "managed_install",
|
||||
"hosted_version_mode": "installation_specific"
|
||||
},
|
||||
"desktop": {
|
||||
"anchor_key": "desktop",
|
||||
"core_version": "0.22.3",
|
||||
"frontend_version": "1.43.18",
|
||||
"generation": "legacy_fixed_bundle",
|
||||
"hosted_version_mode": "fixed"
|
||||
}
|
||||
},
|
||||
"last_validated_date": "2026-07-30",
|
||||
"matrix_version": "v0.2.9",
|
||||
"policy": {
|
||||
"max_age_days": 45,
|
||||
"warn_age_days": 30
|
||||
},
|
||||
"schema_version": 1
|
||||
"schema_version": 2
|
||||
}
|
||||
```
|
||||
|
||||
@@ -28,9 +45,10 @@ This document tracks the current reference anchors and validated environments fo
|
||||
|
||||
| Component | Validated Range | Best Effort / Experimental | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **ComfyUI** | `1377a2f7` reference anchor (`v0.27.0-47-g1377a2f7`; `pyproject.toml` version `0.27.0`) | Older tagged snapshots | Current local upstream reference repo snapshot used for compatibility review |
|
||||
| **ComfyUI Frontend** | `1.48.1` reference anchor (`ceb5ae1eba`; `v1.48.1-1-gceb5ae1eba`) | Minor drift around the anchor | Sidebar extension contract remains compatible; prefer the current sidebar store API with deprecated facade fallback |
|
||||
| **ComfyUI Desktop** | `0.9.4 (core 0.22.3 / frontend 1.43.18)` reference anchor | Desktop bundle may lag standalone frontend | Treat desktop parity as a distinct host surface, not an alias of standalone frontend HEAD |
|
||||
| **ComfyUI** | `9cf91339` reference anchor (`v0.29.0-12-g9cf91339`; `pyproject.toml` version `0.29.0`) | Older tagged snapshots | Current local upstream reference repo snapshot used for compatibility review |
|
||||
| **ComfyUI Frontend** | `1.49.1` reference anchor (`4b3866b838`; `v1.49.1-19-g4b3866b838`) | Minor drift around the anchor | Sidebar extension contract remains compatible; prefer the current sidebar store API with deprecated facade fallback |
|
||||
| **Legacy Desktop** | `0.9.4 (core 0.22.3 / frontend 1.43.18)` reference anchor | Legacy fixed bundle may lag standalone frontend | Preserve the recorded fixed-bundle contract for existing parity coverage |
|
||||
| **Current Comfy-Desktop** | `1.0.32-rc.1` reference anchor (`85e28b7a`; `v1.0.32-rc.1-3-g85e28b7`) | Hosted component versions vary by installation | Treat the managed-install generation separately; do not infer fixed core/frontend versions from the application release |
|
||||
| **Python** | 3.10, 3.11, 3.12 | 3.9 | 3.13 not yet validated |
|
||||
| **Torch** | 2.1.2+ | 1.13+ | CUDA 11.8/12.1 verified |
|
||||
|
||||
@@ -38,7 +56,8 @@ This document tracks the current reference anchors and validated environments fo
|
||||
|
||||
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow, including `/api`-prefixed canonical API routing.
|
||||
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, while inactive subgraph diagnostics and promoted-widget behavior remain regression-sensitive seams.
|
||||
- **Desktop host surface**: desktop currently embeds frontend `1.43.18`, which still lags the standalone frontend `1.48.1` reference. Validate desktop-specific behavior against the desktop anchor instead of assuming standalone-frontend parity.
|
||||
- **Legacy Desktop host surface**: Desktop `0.9.4` embeds frontend `1.43.18`, which lags the standalone frontend `1.49.1` reference. Validate this fixed bundle against its own anchor.
|
||||
- **Current Comfy-Desktop host surface**: application `1.0.32-rc.1` is a managed-install generation. Its hosted ComfyUI and frontend versions are `installation_specific`; the application anchor must not be cross-wired into fixed hosted-version claims.
|
||||
|
||||
## Residual Host-Contract Decisions
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Closure note:
|
||||
- this intake reference is now historical context only
|
||||
- the residual wave was closed during `S91`, with GitHub `Code scanning` and `Secret scanning` reduced to `0` open findings on 2026-04-08
|
||||
|
||||
## 2. Current Residual Findings Baseline
|
||||
## 2. Intake Residual Findings Baseline
|
||||
|
||||
Authenticated GitHub Security review showed this residual baseline at intake:
|
||||
|
||||
@@ -25,25 +25,25 @@ Authenticated GitHub Security review showed this residual baseline at intake:
|
||||
Residual CodeQL families at intake:
|
||||
|
||||
1. `py/path-injection`
|
||||
- current concentration: `services/model_manager_transfer.py`
|
||||
- intake concentration: `services/model_manager_transfer.py`
|
||||
- count at intake: `9`
|
||||
2. `py/weak-sensitive-data-hashing`
|
||||
- current concentration: `services/redaction.py`, `services/audit.py`, `services/bridge_token_lifecycle.py`
|
||||
- intake concentration: `services/redaction.py`, `services/audit.py`, `services/bridge_token_lifecycle.py`
|
||||
- count at intake: `3`
|
||||
3. `py/stack-trace-exposure`
|
||||
- current concentration: `connector/platforms/slack_webhook.py`, `connector/platforms/feishu_webhook.py`
|
||||
- intake concentration: `connector/platforms/slack_webhook.py`, `connector/platforms/feishu_webhook.py`
|
||||
- count at intake: `2`
|
||||
4. `py/clear-text-logging-sensitive-data`
|
||||
- current concentration: `api/bridge.py`, `services/audit.py`
|
||||
- intake concentration: `api/bridge.py`, `services/audit.py`
|
||||
- count at intake: `2`
|
||||
5. `py/clear-text-storage-sensitive-data`
|
||||
- current concentration: `services/audit.py`
|
||||
- intake concentration: `services/audit.py`
|
||||
- count at intake: `1`
|
||||
6. `py/xml-bomb`
|
||||
- current concentration: `connector/platforms/wechat_webhook.py`
|
||||
- intake concentration: `connector/platforms/wechat_webhook.py`
|
||||
- count at intake: `1`
|
||||
7. `js/incomplete-sanitization`
|
||||
- current concentration: `tests/e2e/specs/notifications.spec.js`
|
||||
- intake concentration: `tests/e2e/specs/notifications.spec.js`
|
||||
- count at intake: `1`
|
||||
|
||||
Residual secret-scanning family:
|
||||
|
||||
@@ -122,7 +122,7 @@ interface ContextAction {
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Parameter Lab (F52)
|
||||
## 3. Parameter Lab
|
||||
|
||||
Contracts for bounded parameter sweeps and experiment orchestration.
|
||||
|
||||
@@ -135,17 +135,15 @@ Contracts for bounded parameter sweeps and experiment orchestration.
|
||||
{
|
||||
"node_id": "10",
|
||||
"widget_name": "cfg",
|
||||
"values": [6.0, 7.0, 8.0]
|
||||
"values": [6.0, 7.0, 8.0],
|
||||
"strategy": "grid"
|
||||
},
|
||||
{
|
||||
"node_id": "3",
|
||||
"node_id": "loader-alpha",
|
||||
"widget_name": "seed",
|
||||
"strategy": "random",
|
||||
"count": 3
|
||||
"values": [41, 42]
|
||||
}
|
||||
],
|
||||
"max_runs": 20,
|
||||
"batch_size": 1
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -153,6 +151,22 @@ Contract notes:
|
||||
|
||||
- `node_id` is a string-preserving host graph identifier. It may be numeric text such as `"10"` or a non-numeric host ID, and clients must not coerce it to a number when storing, comparing, or replaying experiment parameters.
|
||||
- Experiment parameter keys such as `"10.cfg"` are display/storage keys derived from the original `node_id` plus `widget_name`; they are not a separate numeric node contract.
|
||||
- Sweep values are limited to bounded strings, booleans, integers, and finite numbers. Null,
|
||||
arrays, objects, non-finite numbers, overlong strings, presentation-ambiguous duplicates, and
|
||||
unsupported strategies fail validation instead of being coerced.
|
||||
- Sweep creation supports `grid` strategy only. The backend policy is authoritative and limits a
|
||||
request to 5 MiB, workflow text to 4 MiB, eight dimensions, 50 values per dimension, and 50
|
||||
generated combinations. Compare creation accepts at most eight scalar items.
|
||||
|
||||
### Queue ownership receipt
|
||||
|
||||
- The coordinator observes the host's reviewed `promptQueueing` and `promptQueued` request
|
||||
boundaries, correlating their integer `requestId` and `batchCount` fields.
|
||||
- It writes a transient UUID receipt only into the matching serialized workflow and returns the
|
||||
exact `promptId` / `requestId` pair used to route bounded lifecycle event metadata.
|
||||
- Unsupported event APIs, malformed or missing boundaries, pre-existing unobserved host queue
|
||||
activity, receipt collisions, timeouts, and ambiguous batch ownership fail explicitly. There is
|
||||
no fallback to a globally recent prompt ID.
|
||||
|
||||
### Experiment Result Schema (JSON)
|
||||
|
||||
|
||||
@@ -7,6 +7,72 @@ Newest entries appear first.
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Startup, security posture, and architecture boundaries hardened</strong></summary>
|
||||
|
||||
- Added a dependency-light production source verifier to pre-commit. It parses tracked Python
|
||||
imports without importing application modules and fails on unknown ownership, forbidden
|
||||
dependency direction, cycles, and unreviewed dynamic imports.
|
||||
- Patched transitive frontend development dependencies `ws`, `postcss`, and resolver-owned
|
||||
`nanoid` without changing the root manifest or runtime dependency boundary. Windows/Linux
|
||||
full-test, pre-push, and CI security paths now run a fresh `npm ci` and block high/critical
|
||||
findings across the complete production and development dependency tree.
|
||||
- Replaced coarse startup reporting with typed, redacted phase and state outcomes, bounded retry
|
||||
and timing metadata, and explicit optional warmup results.
|
||||
- Consolidated process-static deployment and security decisions into one immutable, secret-free
|
||||
effective posture snapshot reused by startup, control-plane, and surface guards.
|
||||
- Moved startup lifecycle, route registration, and effective posture implementations into focused
|
||||
service-domain owner packages while preserving legacy module identity aliases.
|
||||
- Replaced the public systemd environment file with an `.env.example`-style template and retained
|
||||
a hard version-control boundary around secret-bearing environment files.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Host alignment, Parameter Lab, and native workflow ownership refreshed</strong></summary>
|
||||
|
||||
- Split host metadata between legacy fixed-bundle Desktop and current managed-install
|
||||
Comfy-Desktop. Presence of `window.__comfyDesktop2` identifies the current host generation but
|
||||
never authorizes privileged bridge capability calls.
|
||||
- Excluded ComfyUI's `datasets` user-data root from model inventory and Model Manager destination
|
||||
handling so training data is not treated as managed model weights.
|
||||
- Bounded Parameter Lab creation and persistence to string, boolean, integer, and finite-number
|
||||
values, with byte/count limits, grid-only sweep validation, and explicit rejection of nested or
|
||||
ambiguous values.
|
||||
- Correlated Parameter Lab queue ownership through reviewed `promptQueueing` / `promptQueued`
|
||||
request IDs and transient workflow receipts, failing closed on unsupported, malformed, busy, or
|
||||
ambiguous host queue boundaries.
|
||||
- Recognized advanced 3D `result` references as bounded source links without consuming later
|
||||
metadata or rendering binary content.
|
||||
- Documented native ComfyUI ownership for video/webcam inputs, audio and text-to-speech flows, and
|
||||
the Graph/Workflows workspace instead of introducing duplicate OpenClaw node or workspace stacks.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Maintainability, scale safeguards, and verification governance strengthened</strong></summary>
|
||||
|
||||
- Added a pinned incremental Ruff/Mypy policy to local, pre-commit, and CI validation. Existing
|
||||
debt remains explicitly governed while new production-path findings fail the gate.
|
||||
- Established deterministic scale baselines for 10,000-record jobs history, bounded connector
|
||||
summaries, and 1,024 frontend output refs. Exact call counts, payload bounds, and stable digests
|
||||
are enforced; host-sensitive elapsed time remains advisory.
|
||||
- Classified and hardened selected config, connector, and platform-adapter exception boundaries,
|
||||
preserving cancellation, compatibility fallback, public status mapping, and redacted logging.
|
||||
- Decomposed API route and configuration ownership, connector command dispatch, Slack and Feishu
|
||||
ingress/installation/delivery seams, and frontend API/Settings ownership behind stable facades.
|
||||
Public routes, patch seams, security controls, singleton identity, DOM structure, and host
|
||||
lifecycle behavior remain contract-tested.
|
||||
- Promoted the backend coverage floor from 45% to 55% only after reconstructing two consecutive
|
||||
release snapshots, retaining full-suite artifact hashes and all required hotspot percentages,
|
||||
and assigning targeted regression owners. Incomplete, nonconsecutive, malformed, or atomically
|
||||
mismatched promotion evidence now fails closed.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary><strong>Secure jobs visibility and connector summaries completed</strong></summary>
|
||||
|
||||
- Replaced the placeholder jobs listing with an Admin-only, versioned in-process read
|
||||
@@ -142,7 +208,7 @@ Newest entries appear first.
|
||||
|
||||
<summary><strong>Verification governance, config bootstrap hygiene, and connector env hardening aligned with the current runtime</strong></summary>
|
||||
|
||||
- Promoted the staged coverage-ratchet baseline to the enforced `45%` floor, added retained review-cycle evidence for hotspot families, and wired backend coverage collection through one shared local/CI helper instead of ad hoc `fail_under` edits.
|
||||
- Promoted the staged coverage-ratchet baseline to the then-enforced `45%` floor, added retained review-cycle evidence for hotspot families, and wired backend coverage collection through one shared local/CI helper instead of ad hoc `fail_under` edits.
|
||||
- Added focused connector and config/bootstrap hotspot regressions, reviewed the governed hotspot-family coverage summaries, and retired the temporary promotion-gap exceptions now that both promotion-blocking families are represented by explicit review evidence.
|
||||
- Added fail-closed test-debt governance for no-skip modules and mutation-survivor allowlist entries, with explicit `reason` and `review_after` metadata now enforced by the standard full-test flow.
|
||||
- Hardened pack metadata/version fallback parsing and made config/bootstrap imports side-effect-safe, so pack version fallback stays deterministic and importing config helpers no longer creates the state directory or log file before first real use.
|
||||
@@ -261,7 +327,7 @@ Newest entries appear first.
|
||||
<summary><strong>Exception-fidelity cleanup and verification-governance baseline completed</strong></summary>
|
||||
|
||||
- Preserved original traceback origins on the remaining planner/refiner/vision/config failure paths and aligned request-time default `LLMClient` refresh so runtime config hot-reload no longer mutates long-lived service state just to get a fresh client.
|
||||
- Added explicit coverage governance in `pyproject.toml`, including the active `45%` `fail_under`, visible missing-line reporting, and skip-covered output, so baseline quality drift is no longer implicit.
|
||||
- Added explicit coverage governance in `pyproject.toml`, including the then-active `45%` `fail_under`, visible missing-line reporting, and skip-covered output, so baseline quality drift is no longer implicit.
|
||||
- Added a stdlib-only governance verifier that fails closed when coverage config, adversarial mutation thresholds, SOP guidance, or mutation-survivor allowlist shape drift away from the enforced baseline.
|
||||
- Wired the governance verifier into Linux/Windows full-test flows and the repo pre-push gate, keeping local CI-parity checks aligned with the enforced verification contract.
|
||||
- Re-validated the full implementation on WSL with the full SOP gate: detect-secrets, pre-commit, governance verification, backend full suites, adaptive adversarial gate, and Playwright E2E.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- **Environment**: macOS, older Windows versions.
|
||||
- **Python**: 3.12.
|
||||
- **ComfyUI**: nightly builds and farther-from-anchor upstream drift.
|
||||
- **Desktop host**: desktop bundle variants outside the current recorded desktop anchor, including cases where the embedded frontend lags standalone frontend.
|
||||
- **Desktop host**: legacy fixed-bundle variants outside the recorded legacy anchor and current managed-install variants whose installed host components fall outside their own supported anchors.
|
||||
|
||||
### Tier 3: Unsupported
|
||||
|
||||
@@ -35,8 +35,9 @@
|
||||
## Compatibility Anchor Policy
|
||||
|
||||
- The authoritative compatibility reference points are recorded in [`compatibility_matrix.md`](compatibility_matrix.md).
|
||||
- `ComfyUI`, standalone `ComfyUI_frontend`, and `desktop` are tracked as separate host surfaces.
|
||||
- Desktop should not be assumed to match standalone frontend HEAD; the embedded frontend version may intentionally lag and must be evaluated against its own recorded bundle anchor.
|
||||
- `ComfyUI`, standalone `ComfyUI_frontend`, legacy `desktop`, and current `comfy_desktop` are tracked as separate anchors.
|
||||
- Legacy Desktop is a fixed bundle and must be evaluated against its recorded core/frontend versions.
|
||||
- Current Comfy-Desktop is a managed-install generation; hosted ComfyUI and frontend versions are installation-specific and must not be inferred from the application version.
|
||||
- Upstream reference refreshes should update the matrix anchors before being treated as the new default support baseline.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
@@ -45,12 +45,19 @@ Retained release-cycle review evidence lives in:
|
||||
|
||||
- `tests/coverage_promotion_reviews.json`
|
||||
|
||||
The current enforced stage is `ratchet-45`, which means the repository floor is now `fail_under = 45.0` and future promotions must retain at least two reviewed cycles for the previous stage.
|
||||
The current enforced stage is `ratchet-55`, which means the repository floor is now
|
||||
`fail_under = 55.0`. The promotion is backed by two consecutive ratchet-45 release-cycle
|
||||
reviews with immutable release commits, full-suite artifact hashes, all required hotspot
|
||||
percentages, and named regression owners.
|
||||
|
||||
## Governance Baseline
|
||||
|
||||
- `tests/coverage_governance_policy.json` is the source of truth for the current enforced floor, next planned ratchet target, hotspot families, and temporary exceptions.
|
||||
- `pyproject.toml` coverage settings must stay aligned with the active stage floor declared in `tests/coverage_governance_policy.json`.
|
||||
- `tests/coverage_promotion_reviews.json` is the retained promotion-evidence ledger for reviewed hotspot summaries across release cycles.
|
||||
- Ratchet-55 evidence must identify consecutive release boundaries, the reviewed commit,
|
||||
full-suite command and artifact SHA-256, all required hotspot percentages, and owned suites.
|
||||
- Rollback is atomic: a future approved rollback must move both the policy current stage and
|
||||
`pyproject.toml` floor together; config drift fails the governance check.
|
||||
- Test-debt governance remains fail-closed; review metadata such as `reason` and `review_after` must stay current for governed skip-policy and mutation-survivor entries.
|
||||
- Detailed CI-gate composition and merge requirements remain documented in `docs/release/ci_regression_policy.md` and `tests/TEST_SOP.md`.
|
||||
|
||||
@@ -6,6 +6,8 @@ This guide explains the startup security model and bridge compatibility behavior
|
||||
|
||||
- Runtime profile selection
|
||||
- Hardened startup enforcement behavior
|
||||
- Typed startup lifecycle diagnostics
|
||||
- Process-static effective security posture
|
||||
- External tool sandbox diagnostics
|
||||
- Module startup boundaries
|
||||
- Bridge protocol handshake compatibility
|
||||
@@ -53,18 +55,48 @@ If a critical startup gate fails, initialization aborts deterministically instea
|
||||
|
||||
The health response includes a `startup` diagnostic object with:
|
||||
|
||||
- `state`: `starting`, `ready`, `degraded-warmup`, or `fatal-startup`
|
||||
- `schema_version`: diagnostic schema version
|
||||
- `phase`: `package_import`, `required_initialization`, `host_wait`,
|
||||
`route_registration`, `complete`, or `optional_warmup`
|
||||
- `state`: `starting`, `initializing`, `waiting_for_host`, `registering_routes`, `ready`,
|
||||
`degraded`, or `fatal`
|
||||
- `reason_code`: stable, content-free transition reason
|
||||
- `ready`: whether required route/service startup completed
|
||||
- `fatal`: bounded fatal-startup details when required startup fails
|
||||
- `warmups`: bounded status for optional background warmups
|
||||
- `degraded` and `fatal`: explicit terminal posture flags
|
||||
- `attempt` and `max_attempts`: bounded host-wait retry progress
|
||||
- `elapsed_ms`, `phase_elapsed_ms`, and `ready_elapsed_ms`: bounded lifecycle timing
|
||||
- `warmups`: bounded optional warmup entries with name, state, reason, timeout, and duration
|
||||
|
||||
Required startup work still fails closed. Optional warmups such as model inventory refresh run after route registration and do not block baseline API availability. Their failures or timeouts are reported as `degraded-warmup`.
|
||||
Required startup work still fails closed. Optional warmups such as model inventory refresh run after
|
||||
route registration and do not block baseline API availability. A failed or timed-out optional
|
||||
warmup changes the startup state to `degraded`; individual warmup states are `pending`, `running`,
|
||||
`succeeded`, `failed`, or `timed_out`.
|
||||
|
||||
Optional warmup timeout can be tuned with:
|
||||
|
||||
- `OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC`
|
||||
- legacy alias: `MOLTBOT_STARTUP_WARMUP_TIMEOUT_SEC`
|
||||
|
||||
### Effective security posture snapshot
|
||||
|
||||
During the process-wide route bootstrap, OpenClaw resolves deployment, runtime, connector,
|
||||
control-plane, and surface decisions once into an immutable `EffectiveSecurityPosture` snapshot.
|
||||
The snapshot records configuration presence and stable decision codes, not secret values.
|
||||
|
||||
Startup gates, control-plane policy, and surface authorization reuse this same object identity so
|
||||
process-static security decisions cannot drift between modules. Request-dynamic controls such as
|
||||
authentication, replay checks, and rate limiting still evaluate each request using their normal
|
||||
runtime inputs.
|
||||
|
||||
The owner modules are:
|
||||
|
||||
- `services/bootstrap/lifecycle.py`
|
||||
- `services/bootstrap/registration.py`
|
||||
- `services/posture/effective.py`
|
||||
|
||||
Legacy imports remain identity-preserving aliases. See
|
||||
[Service Domain Packages](architecture/service_domain_packages.md) for the ownership contract.
|
||||
|
||||
## Public deployment shared-surface acknowledgement
|
||||
|
||||
When running deployment profile checks for public posture (`OPENCLAW_DEPLOYMENT_PROFILE=public`),
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
- `web/openclaw_notification_center.js`
|
||||
- `web/openclaw_banner_manager.js`
|
||||
- `web/openclaw_tabs.js`
|
||||
- `web/openclaw_api.js` plus focused config/generation/resource/model/event API owners
|
||||
- `web/tabs/settings_tab.js` plus focused status/LLM/secrets/logs/DOM/lifecycle owners
|
||||
- `web/admin_console_app.js`
|
||||
- `web/admin_console_api.js`
|
||||
- Runtime model:
|
||||
@@ -69,7 +71,8 @@ Scored 1-5 (higher is better), weighted by current risk profile:
|
||||
|
||||
1. OpenClaw frontend is host-coupled to ComfyUI extension lifecycle and remount behavior; framework migration introduces significant integration and lifecycle risk with limited near-term operator value.
|
||||
2. Current architecture already has critical stability controls (`ErrorBoundary`, tab remount safety, capability-gated registration, compatibility aliases, Vitest + Playwright lanes).
|
||||
Recent decomposition work further reduced shell/admin/runtime hotspot size without introducing a framework dependency.
|
||||
Recent decomposition work further reduced shell, API, Settings, and admin/runtime hotspot size
|
||||
without introducing a framework dependency, while adding explicit stale-render disposal.
|
||||
3. Most remaining product priorities are functionality and security features, not frontend rendering abstraction gaps; migration now would consume high-risk bandwidth with weak ROI.
|
||||
|
||||
## Decision
|
||||
|
||||
Generated
+10
-10
@@ -1207,9 +1207,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1314,9 +1314,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1334,7 +1334,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1803,9 +1803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+17
-5
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-openclaw"
|
||||
description = "Your own personal AIGC Factory. Any picture. Any reel. The Comfy way.©️"
|
||||
version = "1.0.0"
|
||||
version = "1.0.7"
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -71,9 +71,12 @@ target-version = ["py310"]
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
warn_unused_configs = false
|
||||
disallow_untyped_defs = false # Start lenient, can tighten later
|
||||
disallow_any_unimported = false
|
||||
explicit_package_bases = true
|
||||
no_site_packages = true
|
||||
ignore_missing_imports = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
@@ -81,8 +84,17 @@ warn_no_return = true
|
||||
check_untyped_defs = true
|
||||
strict_equality = true
|
||||
|
||||
# Paths to check
|
||||
files = ["*.py", "tests/**/*.py"]
|
||||
# Production paths governed by tests/static_analysis_policy.json.
|
||||
files = [
|
||||
"__init__.py",
|
||||
"config.py",
|
||||
"api",
|
||||
"connector",
|
||||
"models",
|
||||
"nodes",
|
||||
"services",
|
||||
"scripts",
|
||||
]
|
||||
|
||||
# Ignore missing imports for ComfyUI and external packages
|
||||
[[tool.mypy.overrides]]
|
||||
@@ -127,7 +139,7 @@ omit = [
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 45.0
|
||||
fail_under = 55.0
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
precision = 2
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Development/test-only static-analysis toolchain.
|
||||
# Keep exact pins aligned with tests/static_analysis_policy.json.
|
||||
ruff==0.15.20
|
||||
mypy==2.2.0
|
||||
@@ -55,7 +55,12 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--anchor-desktop",
|
||||
default=None,
|
||||
help="Observed ComfyUI Desktop anchor/version (optional)",
|
||||
help="Observed legacy ComfyUI Desktop anchor/version (optional)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--anchor-comfy-desktop",
|
||||
default=None,
|
||||
help="Observed current Comfy-Desktop anchor/version (optional)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--updated-by",
|
||||
@@ -88,6 +93,7 @@ def main() -> int:
|
||||
comfyui=args.anchor_comfyui,
|
||||
comfyui_frontend=args.anchor_frontend,
|
||||
desktop=args.anchor_desktop,
|
||||
comfy_desktop=args.anchor_comfy_desktop,
|
||||
)
|
||||
result = run_refresh_workflow(
|
||||
matrix_path=args.matrix_path,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Portable digest helpers for governed UTF-8 text contracts. */
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
|
||||
export function stableTextDigest(filePath) {
|
||||
// IMPORTANT: normalize text newlines; raw hashing breaks frozen contracts after Windows checkout.
|
||||
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n?/g, "\n");
|
||||
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Portable digest and write helpers for governed text contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def normalize_text_newlines(payload: bytes) -> bytes:
|
||||
"""Return text bytes with CRLF and lone CR represented as LF."""
|
||||
# IMPORTANT: normalize text newlines; raw hashing breaks frozen contracts after Windows checkout.
|
||||
return payload.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
||||
|
||||
|
||||
def stable_text_digest(path: Path) -> str:
|
||||
"""Hash governed text independently of checkout newline representation."""
|
||||
return hashlib.sha256(normalize_text_newlines(path.read_bytes())).hexdigest()
|
||||
|
||||
|
||||
def write_text_lf(path: Path, text: str) -> None:
|
||||
"""Write UTF-8 contract text with explicit LF newlines on every platform."""
|
||||
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(text)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Lightweight closeout evidence for changes classified as high risk.
|
||||
|
||||
This pilot deliberately reuses the adversarial gate's path classifier. Standard-
|
||||
risk and empty diffs remain outside this workflow and produce no receipt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from run_adversarial_gate import (
|
||||
DEFAULT_HIGH_RISK_PATTERNS,
|
||||
_filter_high_risk_files,
|
||||
_run_git_diff,
|
||||
)
|
||||
|
||||
SCHEMA = "openclaw-high-risk-receipt/1"
|
||||
EXACT_COMMIT_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
|
||||
ITEM_RE = re.compile(r"[A-Z][A-Z0-9-]{0,31}\Z")
|
||||
|
||||
|
||||
class AcceptanceError(RuntimeError):
|
||||
"""A safe, user-actionable closeout validation failure."""
|
||||
|
||||
|
||||
def _run_git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _git_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
raise AcceptanceError("current directory is not inside a Git worktree")
|
||||
return Path(result.stdout.strip()).resolve()
|
||||
|
||||
|
||||
def _resolve_commit(repo_root: Path, reference: str, label: str) -> str:
|
||||
if not reference or reference.startswith("-"):
|
||||
raise AcceptanceError(f"{label} must be a valid Git revision")
|
||||
result = _run_git(repo_root, "rev-parse", "--verify", f"{reference}^{{commit}}")
|
||||
commit = result.stdout.strip().lower()
|
||||
if result.returncode != 0 or not EXACT_COMMIT_RE.fullmatch(commit):
|
||||
raise AcceptanceError(f"{label} does not resolve to a commit")
|
||||
return commit
|
||||
|
||||
|
||||
def _require_ancestor(repo_root: Path, base_commit: str, candidate_commit: str) -> None:
|
||||
result = _run_git(
|
||||
repo_root,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
base_commit,
|
||||
candidate_commit,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AcceptanceError("base commit is not an ancestor of candidate commit")
|
||||
|
||||
|
||||
def _changed_files(
|
||||
repo_root: Path, base_commit: str, candidate_commit: str
|
||||
) -> list[str]:
|
||||
previous_cwd = Path.cwd()
|
||||
try:
|
||||
os.chdir(repo_root)
|
||||
return [str(path) for path in _run_git_diff(base_commit, candidate_commit)]
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
|
||||
def _require_closeout_state(
|
||||
repo_root: Path,
|
||||
candidate_argument: str,
|
||||
candidate_commit: str,
|
||||
) -> str:
|
||||
if not EXACT_COMMIT_RE.fullmatch(candidate_argument):
|
||||
raise AcceptanceError(
|
||||
"high-risk candidate must be an exact 40-character commit SHA"
|
||||
)
|
||||
|
||||
head_commit = _resolve_commit(repo_root, "HEAD", "HEAD")
|
||||
if candidate_commit != head_commit:
|
||||
raise AcceptanceError("candidate commit must equal current HEAD")
|
||||
|
||||
branch_result = _run_git(repo_root, "branch", "--show-current")
|
||||
branch = branch_result.stdout.strip()
|
||||
if branch_result.returncode != 0 or branch != "dev":
|
||||
raise AcceptanceError("high-risk closeout must run on branch dev")
|
||||
|
||||
status_result = _run_git(
|
||||
repo_root,
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--untracked-files=no",
|
||||
)
|
||||
if status_result.returncode != 0 or status_result.stdout.strip():
|
||||
raise AcceptanceError("tracked worktree and index must be clean")
|
||||
return branch
|
||||
|
||||
|
||||
def _require_identity(value: str | None, label: str) -> str:
|
||||
if value is None or not value.strip():
|
||||
raise AcceptanceError(f"{label} is required for high-risk closeout")
|
||||
normalized = value.strip()
|
||||
if len(normalized) > 128 or any(ord(character) < 32 for character in normalized):
|
||||
raise AcceptanceError(f"{label} contains invalid characters")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_closeout_arguments(args: argparse.Namespace) -> tuple[str, str, str]:
|
||||
if args.item is None or not ITEM_RE.fullmatch(args.item):
|
||||
raise AcceptanceError("item must be an uppercase roadmap identifier")
|
||||
|
||||
implementer = _require_identity(args.implementer, "implementer")
|
||||
reviewer = _require_identity(args.reviewer, "reviewer")
|
||||
if implementer.casefold() == reviewer.casefold():
|
||||
raise AcceptanceError("reviewer must be distinct from implementer")
|
||||
if args.review_verdict != "APPROVED":
|
||||
raise AcceptanceError("review verdict must be APPROVED")
|
||||
if args.full_gate_status != "PASS":
|
||||
raise AcceptanceError("full TEST_SOP gate must be PASS")
|
||||
return args.item, implementer, reviewer
|
||||
|
||||
|
||||
def _resolve_output(repo_root: Path, output: str | None) -> tuple[Path, str]:
|
||||
if output is None or not output.strip():
|
||||
raise AcceptanceError("output is required for high-risk closeout")
|
||||
|
||||
candidate = Path(output)
|
||||
output_path = (
|
||||
(repo_root / candidate).resolve()
|
||||
if not candidate.is_absolute()
|
||||
else candidate.resolve()
|
||||
)
|
||||
planning_root = (repo_root / ".planning").resolve()
|
||||
try:
|
||||
relative_to_planning = output_path.relative_to(planning_root)
|
||||
relative_to_repo = output_path.relative_to(repo_root)
|
||||
except ValueError as exc:
|
||||
raise AcceptanceError(
|
||||
"output must be under the repository .planning directory"
|
||||
) from exc
|
||||
if relative_to_planning == Path("."):
|
||||
raise AcceptanceError("output must name a file under .planning")
|
||||
if output_path.exists():
|
||||
raise AcceptanceError("output already exists")
|
||||
|
||||
relative_posix = relative_to_repo.as_posix()
|
||||
ignored = _run_git(
|
||||
repo_root,
|
||||
"check-ignore",
|
||||
"-v",
|
||||
"--no-index",
|
||||
"--",
|
||||
relative_posix,
|
||||
)
|
||||
if ignored.returncode != 0:
|
||||
raise AcceptanceError("output must be ignored by repository Git rules")
|
||||
return output_path, relative_posix
|
||||
|
||||
|
||||
def _write_receipt(output_path: Path, receipt: dict[str, object]) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with output_path.open("x", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(receipt, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
except FileExistsError as exc:
|
||||
raise AcceptanceError("output already exists") from exc
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate and record the lightweight high-risk closeout pilot."
|
||||
)
|
||||
parser.add_argument("--base", required=True, help="Base Git revision.")
|
||||
parser.add_argument("--candidate", default="HEAD", help="Candidate Git revision.")
|
||||
parser.add_argument("--item")
|
||||
parser.add_argument("--implementer")
|
||||
parser.add_argument("--reviewer")
|
||||
parser.add_argument("--review-verdict")
|
||||
parser.add_argument("--full-gate-status")
|
||||
parser.add_argument("--output")
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
repo_root = _git_root()
|
||||
base_commit = _resolve_commit(repo_root, args.base, "base")
|
||||
candidate_commit = _resolve_commit(repo_root, args.candidate, "candidate")
|
||||
|
||||
# IMPORTANT: ancestry is validated before classification so unrelated
|
||||
# histories cannot be mistaken for a standard-risk, non-applicable diff.
|
||||
_require_ancestor(repo_root, base_commit, candidate_commit)
|
||||
changed_files = _changed_files(repo_root, base_commit, candidate_commit)
|
||||
high_risk_changed = _filter_high_risk_files(
|
||||
changed_files, DEFAULT_HIGH_RISK_PATTERNS
|
||||
)
|
||||
if not high_risk_changed:
|
||||
print("HIGH_RISK_ACCEPTANCE: NOT_APPLICABLE")
|
||||
return 0
|
||||
|
||||
branch = _require_closeout_state(repo_root, args.candidate, candidate_commit)
|
||||
item, implementer, reviewer = _validate_closeout_arguments(args)
|
||||
output_path, output_label = _resolve_output(repo_root, args.output)
|
||||
receipt: dict[str, object] = {
|
||||
"schema": SCHEMA,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"item": item,
|
||||
"branch": branch,
|
||||
"base_commit": base_commit,
|
||||
"candidate_commit": candidate_commit,
|
||||
"changed_files": changed_files,
|
||||
"high_risk_changed_files": high_risk_changed,
|
||||
"review": {
|
||||
"implementer": implementer,
|
||||
"reviewer": reviewer,
|
||||
"verdict": args.review_verdict,
|
||||
},
|
||||
"gates": {"full_test_sop": args.full_gate_status},
|
||||
"limitations": (
|
||||
"Pilot receipt records declared review and gate results; it is not "
|
||||
"identity authentication or a cryptographic attestation."
|
||||
),
|
||||
}
|
||||
_write_receipt(output_path, receipt)
|
||||
print(f"HIGH_RISK_ACCEPTANCE: PASS ({output_label})")
|
||||
return 0
|
||||
except AcceptanceError as exc:
|
||||
print(f"HIGH_RISK_ACCEPTANCE: FAIL: {exc}")
|
||||
return 1
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
print("HIGH_RISK_ACCEPTANCE: FAIL: repository validation could not complete")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
@@ -80,6 +80,24 @@ report_precommit_repo_drift_and_exit() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_clean_public_worktree() {
|
||||
# CRITICAL: tracked diff snapshots do not include untracked deliverables. A push
|
||||
# gate must validate one clean committed candidate, not local-only files.
|
||||
local status
|
||||
if ! status="$(git status --porcelain --untracked-files=all)"; then
|
||||
echo "[pre-push] ERROR: unable to inspect Git worktree state" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$status" ]; then
|
||||
echo "[pre-push] ERROR: validation requires a clean committed candidate." >&2
|
||||
echo "[pre-push] Commit or remove public changes, then retry." >&2
|
||||
printf '%s\n' "$status" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_clean_public_worktree
|
||||
|
||||
is_wsl() {
|
||||
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
|
||||
}
|
||||
@@ -359,6 +377,10 @@ fi
|
||||
echo "[pre-push] Node version: $(node -v)"
|
||||
echo "[pre-push] 0/10 supply-chain hardening check"
|
||||
"$VENV_PY" scripts/check_supply_chain_hardening.py
|
||||
echo "[pre-push] 0.25/10 frontend dependency install and audit"
|
||||
# IMPORTANT: never accept a warmed or manually changed node_modules tree.
|
||||
npm ci
|
||||
npm audit --audit-level=high
|
||||
echo "[pre-push] 0/7 R120 dependency preflight"
|
||||
"$VENV_PY" scripts/preflight_check.py --strict
|
||||
echo "[pre-push] 1/7 detect-secrets"
|
||||
@@ -426,4 +448,5 @@ MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_adversarial" \
|
||||
echo "[pre-push] 9/9 npm test (Playwright)"
|
||||
npm test
|
||||
|
||||
assert_clean_public_worktree
|
||||
echo "[pre-push] PASS"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from fnmatch import fnmatch
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -14,7 +16,9 @@ REQUIRED_HOTSPOT_FAMILIES = (
|
||||
"config_bootstrap",
|
||||
)
|
||||
MIN_PROMOTION_REVIEW_CYCLES = 2
|
||||
RATCHET55_CRITICAL_FAMILIES = ("safe_io", "security_boundary")
|
||||
RATCHET55_CRITICAL_FAMILIES = REQUIRED_HOTSPOT_FAMILIES
|
||||
_FULL_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -122,7 +126,7 @@ def load_and_validate_policy(path: Path) -> tuple[dict[str, Any] | None, list[st
|
||||
CoverageStage(stage_id=stage_id, min_fail_under=float(min_fail_under))
|
||||
)
|
||||
|
||||
for previous, current in zip(stages, stages[1:]):
|
||||
for previous, current in pairwise(stages):
|
||||
if current.min_fail_under <= previous.min_fail_under:
|
||||
failures.append(
|
||||
"coverage policy: coverage stages must increase strictly by min_fail_under"
|
||||
@@ -172,7 +176,7 @@ def load_and_validate_policy(path: Path) -> tuple[dict[str, Any] | None, list[st
|
||||
if index + 1 < len(stages_raw):
|
||||
policy_next_stage = stages_raw[index + 1].get("id")
|
||||
break
|
||||
if policy_next_stage == "ratchet-55":
|
||||
if current_stage == "ratchet-55" or policy_next_stage == "ratchet-55":
|
||||
families_by_id = {
|
||||
family.get("id"): family
|
||||
for family in family_payload
|
||||
@@ -332,6 +336,131 @@ def load_and_validate_review_evidence(
|
||||
f"coverage review evidence: review {cycle_id!r} must include artifact_reference"
|
||||
)
|
||||
|
||||
if policy.get("current_stage") == "ratchet-55":
|
||||
ratchet45_reviews = [
|
||||
entry
|
||||
for entry in reviews
|
||||
if isinstance(entry, dict) and entry.get("stage_id") == "ratchet-45"
|
||||
]
|
||||
required_families = set(policy.get("required_hotspot_families", []))
|
||||
complete_reviews: list[dict[str, Any]] = []
|
||||
for entry in ratchet45_reviews:
|
||||
cycle_id = entry.get("cycle_id")
|
||||
release_cycle = entry.get("release_cycle")
|
||||
reviewed_commit = entry.get("reviewed_commit")
|
||||
coverage_command = entry.get("coverage_command")
|
||||
artifact_sha256 = entry.get("artifact_sha256")
|
||||
raw_reviewed_families = entry.get("reviewed_hotspot_families")
|
||||
reviewed_families = (
|
||||
set(raw_reviewed_families)
|
||||
if isinstance(raw_reviewed_families, list)
|
||||
else set()
|
||||
)
|
||||
hotspot_percent = entry.get("hotspot_percent_covered")
|
||||
owned_suites = entry.get("owned_regression_suites")
|
||||
|
||||
start_tag = (
|
||||
release_cycle.get("start_tag")
|
||||
if isinstance(release_cycle, dict)
|
||||
else None
|
||||
)
|
||||
end_tag = (
|
||||
release_cycle.get("end_tag")
|
||||
if isinstance(release_cycle, dict)
|
||||
else None
|
||||
)
|
||||
start_commit = (
|
||||
release_cycle.get("start_commit")
|
||||
if isinstance(release_cycle, dict)
|
||||
else None
|
||||
)
|
||||
end_commit = (
|
||||
release_cycle.get("end_commit")
|
||||
if isinstance(release_cycle, dict)
|
||||
else None
|
||||
)
|
||||
release_fields_valid = (
|
||||
isinstance(start_tag, str)
|
||||
and bool(start_tag.strip())
|
||||
and isinstance(end_tag, str)
|
||||
and bool(end_tag.strip())
|
||||
and isinstance(start_commit, str)
|
||||
and _FULL_GIT_SHA_RE.fullmatch(start_commit.lower()) is not None
|
||||
and isinstance(end_commit, str)
|
||||
and _FULL_GIT_SHA_RE.fullmatch(end_commit.lower()) is not None
|
||||
)
|
||||
artifact_valid = (
|
||||
isinstance(artifact_sha256, str)
|
||||
and _SHA256_RE.fullmatch(artifact_sha256.lower()) is not None
|
||||
and isinstance(coverage_command, str)
|
||||
and "run_backend_coverage.py" in coverage_command
|
||||
and "--start-dir tests" in coverage_command
|
||||
)
|
||||
reviewed_commit_valid = (
|
||||
isinstance(reviewed_commit, str)
|
||||
and _FULL_GIT_SHA_RE.fullmatch(reviewed_commit.lower()) is not None
|
||||
and isinstance(end_commit, str)
|
||||
and reviewed_commit.lower() == end_commit.lower()
|
||||
)
|
||||
hotspot_valid = (
|
||||
reviewed_families == required_families
|
||||
and isinstance(hotspot_percent, dict)
|
||||
and all(
|
||||
isinstance(hotspot_percent.get(family), (int, float))
|
||||
and 0.0 <= float(hotspot_percent[family]) <= 100.0
|
||||
for family in required_families
|
||||
)
|
||||
)
|
||||
ownership_valid = isinstance(owned_suites, dict) and all(
|
||||
isinstance(owned_suites.get(family), list)
|
||||
and bool(owned_suites[family])
|
||||
and all(
|
||||
isinstance(path, str)
|
||||
and path.startswith("tests/")
|
||||
and path.endswith(".py")
|
||||
for path in owned_suites[family]
|
||||
)
|
||||
for family in required_families
|
||||
)
|
||||
overall = entry.get("overall_percent_covered")
|
||||
overall_valid = isinstance(overall, (int, float)) and float(overall) >= 45.0
|
||||
|
||||
if all(
|
||||
(
|
||||
release_fields_valid,
|
||||
artifact_valid,
|
||||
reviewed_commit_valid,
|
||||
hotspot_valid,
|
||||
ownership_valid,
|
||||
overall_valid,
|
||||
)
|
||||
):
|
||||
complete_reviews.append(entry)
|
||||
else:
|
||||
failures.append(
|
||||
"coverage review evidence: ratchet-55 promotion review "
|
||||
f"{cycle_id!r} requires complete release-cycle evidence, full-suite "
|
||||
"artifact identity, all required hotspots, and owned regression suites"
|
||||
)
|
||||
|
||||
if len(complete_reviews) >= MIN_PROMOTION_REVIEW_CYCLES:
|
||||
for previous, current in pairwise(complete_reviews):
|
||||
previous_cycle = previous.get("release_cycle")
|
||||
current_cycle = current.get("release_cycle")
|
||||
if not isinstance(previous_cycle, dict) or not isinstance(
|
||||
current_cycle, dict
|
||||
):
|
||||
continue
|
||||
if (
|
||||
previous_cycle["end_tag"] != current_cycle["start_tag"]
|
||||
or previous_cycle["end_commit"].lower()
|
||||
!= current_cycle["start_commit"].lower()
|
||||
):
|
||||
failures.append(
|
||||
"coverage review evidence: ratchet-55 requires consecutive release cycles"
|
||||
)
|
||||
break
|
||||
|
||||
return payload, failures
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ DEFAULT_HIGH_RISK_PATTERNS = [
|
||||
"services/access_control.py",
|
||||
"services/tenant_context.py",
|
||||
"api/routes.py",
|
||||
# CRITICAL: keep route-bootstrap owners exact; broad service globs over-escalate CI.
|
||||
"services/bootstrap/registration.py",
|
||||
"services/route_bootstrap_contract.py",
|
||||
"services/security_*.py",
|
||||
"services/startup_profile_gate.py",
|
||||
"services/control_plane.py",
|
||||
@@ -52,7 +55,10 @@ EXTENDED_MUTATION_THRESHOLD = 80.0
|
||||
|
||||
|
||||
def _normalize_rel_path(path: str) -> str:
|
||||
return pathlib.PurePosixPath(path.replace("\\", "/")).as_posix().lstrip("./")
|
||||
normalized = path.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
return pathlib.PurePosixPath(normalized).as_posix()
|
||||
|
||||
|
||||
def _run_git_diff(base: Optional[str], head: Optional[str]) -> List[str]:
|
||||
@@ -116,10 +122,11 @@ def _collect_changed_files(
|
||||
def _filter_high_risk_files(changed_files: List[str], patterns: List[str]) -> List[str]:
|
||||
matched: Set[str] = set()
|
||||
normalized_patterns = [_normalize_rel_path(p) for p in patterns if p.strip()]
|
||||
for f in changed_files:
|
||||
for file_path in changed_files:
|
||||
normalized_file = _normalize_rel_path(file_path)
|
||||
for pattern in normalized_patterns:
|
||||
if fnmatch.fnmatch(f, pattern):
|
||||
matched.add(f)
|
||||
if fnmatch.fnmatch(normalized_file, pattern):
|
||||
matched.add(normalized_file)
|
||||
break
|
||||
return sorted(matched)
|
||||
|
||||
|
||||
@@ -83,14 +83,31 @@ report_precommit_repo_drift_and_exit() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_clean_public_worktree() {
|
||||
# CRITICAL: tracked diff snapshots do not include untracked deliverables. A full
|
||||
# acceptance gate must bind to one clean committed candidate, not local-only files.
|
||||
local status
|
||||
if ! status="$(git status --porcelain --untracked-files=all)"; then
|
||||
echo "[tests] ERROR: unable to inspect Git worktree state" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$status" ]; then
|
||||
echo "[tests] ERROR: acceptance requires a clean committed candidate." >&2
|
||||
echo "[tests] Commit or remove public changes, then rerun." >&2
|
||||
printf '%s\n' "$status" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_clean_public_worktree
|
||||
|
||||
require_cmd node
|
||||
require_cmd npm
|
||||
|
||||
ensure_npm_deps() {
|
||||
if [ -f "$ROOT_DIR/node_modules/@playwright/test/package.json" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "[tests] Installing frontend dependencies via npm ci ..."
|
||||
# IMPORTANT: acceptance must reconcile the complete lockfile; file-presence
|
||||
# shortcuts can silently reuse an invalid or stale development dependency tree.
|
||||
echo "[tests] Reconciling frontend dependencies via npm ci ..."
|
||||
npm ci
|
||||
}
|
||||
|
||||
@@ -123,6 +140,12 @@ if ! "$VENV_PY" -c "import cryptography" >/dev/null 2>&1; then
|
||||
echo "[tests] Installing cryptography into project venv ($VENV_DIR) ..."
|
||||
pip_install_or_fail "required for S57 secrets-at-rest encryption tests" cryptography
|
||||
fi
|
||||
|
||||
if ! "$VENV_PY" -c "import json, sys; from importlib.metadata import version; p=json.load(open('tests/static_analysis_policy.json', encoding='utf-8')); sys.exit(0 if all(version(name)==cfg['version'] for name,cfg in p['tools'].items()) else 1)" >/dev/null 2>&1; then
|
||||
echo "[tests] Installing pinned Ruff/Mypy into project venv ($VENV_DIR) ..."
|
||||
pip_install_or_fail "required for static-analysis policy" -r requirements-quality.txt
|
||||
fi
|
||||
|
||||
if ! "$VENV_PY" -c "import defusedxml" >/dev/null 2>&1; then
|
||||
# IMPORTANT: keep local full-test bootstrap aligned with requirements.txt.
|
||||
echo "[tests] Installing defusedxml into project venv ($VENV_DIR) ..."
|
||||
@@ -194,6 +217,12 @@ echo "[tests] 0/11 supply-chain hardening check"
|
||||
|
||||
ensure_npm_deps
|
||||
|
||||
echo "[tests] 0.25/11 frontend dependency audit"
|
||||
npm audit --audit-level=high
|
||||
|
||||
echo "[tests] 0.5/11 static analysis policy"
|
||||
"$VENV_PY" scripts/verify_static_analysis_policy.py
|
||||
|
||||
echo "[tests] 0/9 R120 dependency preflight"
|
||||
"$VENV_PY" scripts/preflight_check.py --strict
|
||||
|
||||
@@ -254,4 +283,5 @@ echo "[tests] 10/10 frontend E2E"
|
||||
# not assume a warmed local browser cache when running on fresh WSL/Linux hosts.
|
||||
OPENCLAW_PLAYWRIGHT_INSTALL=1 OPENCLAW_PLAYWRIGHT_BROWSERS=chromium npm test
|
||||
|
||||
assert_clean_public_worktree
|
||||
echo "[tests] PASS"
|
||||
|
||||
@@ -54,16 +54,27 @@ function Assert-PreCommitDidNotMutateRepo {
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-CleanPublicWorktree {
|
||||
# CRITICAL: tracked diff snapshots do not include untracked deliverables. A full
|
||||
# acceptance gate must bind to one clean committed candidate, not local-only files.
|
||||
$statusLines = @(& git status --porcelain --untracked-files=all)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[tests] ERROR: unable to inspect Git worktree state"
|
||||
}
|
||||
if ($statusLines.Count -gt 0) {
|
||||
throw "[tests] ERROR: acceptance requires a clean committed candidate. Commit or remove public changes, then rerun.`n$($statusLines -join [Environment]::NewLine)"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-CleanPublicWorktree
|
||||
|
||||
Require-Cmd node
|
||||
Require-Cmd npm
|
||||
|
||||
function Ensure-NpmDeps {
|
||||
$playwrightPkg = Join-Path $root "node_modules\@playwright\test\package.json"
|
||||
if (Test-Path $playwrightPkg) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "[tests] Installing frontend dependencies via npm ci ..."
|
||||
# IMPORTANT: acceptance must reconcile the complete lockfile; file-presence
|
||||
# shortcuts can silently reuse an invalid or stale development dependency tree.
|
||||
Write-Host "[tests] Reconciling frontend dependencies via npm ci ..."
|
||||
Invoke-Checked "npm ci" { npm ci }
|
||||
}
|
||||
|
||||
@@ -195,6 +206,16 @@ if (-not $hasCoverageTomlSupport) {
|
||||
Invoke-Checked "pip install coverage[toml]" { & $venvPython -m pip install "coverage[toml]" }
|
||||
}
|
||||
|
||||
$qualityToolsReady = $true
|
||||
& $venvPython -c "import json, sys; from importlib.metadata import version; p=json.load(open('tests/static_analysis_policy.json', encoding='utf-8')); sys.exit(0 if all(version(name)==cfg['version'] for name,cfg in p['tools'].items()) else 1)" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$qualityToolsReady = $false
|
||||
}
|
||||
if (-not $qualityToolsReady) {
|
||||
Write-Host "[tests] Installing pinned Ruff/Mypy into project venv ..."
|
||||
Invoke-Checked "pip install quality tools" { & $venvPython -m pip install -r requirements-quality.txt }
|
||||
}
|
||||
|
||||
# Ensure Node >= 18
|
||||
$nodeMajor = [int]((& node -p "process.versions.node.split('.')[0]").Trim())
|
||||
if ($nodeMajor -lt 18) {
|
||||
@@ -266,6 +287,13 @@ Invoke-Checked "supply-chain hardening check" {
|
||||
& $venvPython scripts\check_supply_chain_hardening.py
|
||||
}
|
||||
Ensure-NpmDeps
|
||||
Write-Host "[tests] 0.25/11 frontend dependency audit"
|
||||
Invoke-Checked "npm audit" { npm audit --audit-level=high }
|
||||
|
||||
Write-Host "[tests] 0.5/11 static analysis policy"
|
||||
Invoke-Checked "static analysis policy" {
|
||||
& $venvPython scripts/verify_static_analysis_policy.py
|
||||
}
|
||||
|
||||
Write-Host "[tests] 0/8 R120 dependency preflight"
|
||||
Invoke-Checked "preflight_check" { & $venvPython scripts\preflight_check.py --strict }
|
||||
@@ -349,4 +377,5 @@ $env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"
|
||||
# not assume a warmed local browser cache when running on fresh Windows hosts.
|
||||
Invoke-Checked "frontend E2E" { npm test }
|
||||
|
||||
Assert-CleanPublicWorktree
|
||||
Write-Host "[tests] PASS"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Verify the frozen R221 API config facade and governance contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
|
||||
|
||||
CONTRACT_PATH = ROOT / "tests" / "api_config_contract_r221.json"
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _metadata(handler: Any) -> dict[str, Any]:
|
||||
from services.endpoint_manifest import get_metadata
|
||||
|
||||
meta = get_metadata(handler)
|
||||
if meta is None:
|
||||
raise RuntimeError(f"missing endpoint metadata for {handler.__name__}")
|
||||
return {
|
||||
"auth": meta.auth_tier.value,
|
||||
"risk": meta.risk_tier.value,
|
||||
"plane": meta.route_plane.value if meta.route_plane else None,
|
||||
"summary": meta.summary,
|
||||
"description": meta.description,
|
||||
"audit": meta.audit_action,
|
||||
"scopes": list(meta.required_scopes),
|
||||
}
|
||||
|
||||
|
||||
def build_contract() -> dict[str, Any]:
|
||||
from api import config
|
||||
|
||||
handlers = (
|
||||
"config_get_handler",
|
||||
"llm_models_handler",
|
||||
"config_put_handler",
|
||||
"llm_test_handler",
|
||||
"llm_chat_handler",
|
||||
)
|
||||
patch_seams = (
|
||||
"web",
|
||||
"logger",
|
||||
"require_observability_access",
|
||||
"require_admin_token",
|
||||
"require_same_origin_if_no_token",
|
||||
"check_rate_limit",
|
||||
"build_rate_limit_response",
|
||||
"resolve_token_info",
|
||||
"emit_audit_event",
|
||||
"request_tenant_scope",
|
||||
"get_effective_config",
|
||||
"get_runtime_guardrails",
|
||||
"get_settings_schema",
|
||||
"update_config",
|
||||
"get_apply_semantics",
|
||||
"get_admin_token",
|
||||
"payload_contains_runtime_guardrails",
|
||||
"get_llm_egress_controls",
|
||||
"is_loopback_client",
|
||||
"get_client_ip",
|
||||
"resolve_model_list_target",
|
||||
"validate_model_list_target",
|
||||
"fetch_remote_model_list",
|
||||
"get_stale_cached_models",
|
||||
"_cache_get",
|
||||
"_format_llm_ssrf_error",
|
||||
"_llm_insecure_override_enabled",
|
||||
"LLMClient",
|
||||
)
|
||||
schema = config.get_settings_schema()
|
||||
route_contract = ROOT / "tests" / "api_route_contract_r220.json"
|
||||
openapi = ROOT / "docs" / "openapi.yaml"
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"facade_signatures": {
|
||||
name: str(inspect.signature(getattr(config, name))) for name in handlers
|
||||
},
|
||||
"facade_metadata": {
|
||||
name: _metadata(getattr(config, name)) for name in handlers
|
||||
},
|
||||
"patch_seams": list(patch_seams),
|
||||
"provider_catalog": config.PROVIDER_CATALOG,
|
||||
"allowed_llm_keys": sorted(config.ALLOWED_LLM_KEYS),
|
||||
"model_cache": {
|
||||
"max_entries": config._MODEL_LIST_MAX_ENTRIES,
|
||||
"ttl_sec": config._MODEL_LIST_TTL_SEC,
|
||||
"exported_cache_type": type(config._MODEL_LIST_CACHE).__name__,
|
||||
},
|
||||
"settings_schema_sha256": _digest(schema),
|
||||
"apply_semantics": {
|
||||
"provider": config.get_apply_semantics(["provider"]),
|
||||
"model": config.get_apply_semantics(["model"]),
|
||||
"base_url": config.get_apply_semantics(["base_url"]),
|
||||
},
|
||||
"owned_response_matrices": {
|
||||
"config": [
|
||||
"tests.test_s66_api_config_guardrails",
|
||||
"tests.test_r53_apply_semantics",
|
||||
"tests.security.test_r99_sensitive_contract",
|
||||
"tests.test_r219_exception_boundary_phase2",
|
||||
],
|
||||
"models": [
|
||||
"tests.test_api_model_list",
|
||||
"tests.test_r60_model_cache",
|
||||
"tests.test_r123_real_backend_model_list_lane",
|
||||
"tests.test_r155_exception_fidelity",
|
||||
"tests.test_llm_default_allowlist",
|
||||
],
|
||||
"llm": [
|
||||
"tests.test_s28s29_chat_csrf_redaction",
|
||||
"tests.test_r219_exception_boundary_phase2",
|
||||
],
|
||||
},
|
||||
"r220_route_contract_sha256": stable_text_digest(route_contract),
|
||||
"openapi_sha256": stable_text_digest(openapi),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--write-baseline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
actual = build_contract()
|
||||
if args.write_baseline:
|
||||
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
|
||||
print(f"API-CONFIG-CONTRACT-WRITTEN: {CONTRACT_PATH}")
|
||||
return 0
|
||||
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
|
||||
if actual != expected:
|
||||
print("API-CONFIG-CONTRACT-FAIL: frozen config/facade contract drifted")
|
||||
return 1
|
||||
print("API-CONFIG-CONTRACT-PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Verify the frozen R220 API route/facade contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
|
||||
|
||||
CONTRACT_PATH = ROOT / "tests" / "api_route_contract_r220.json"
|
||||
|
||||
|
||||
class _NamedHandler:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.__name__ = name
|
||||
|
||||
|
||||
class _AttributeHandlers:
|
||||
def __getattr__(self, name: str) -> _NamedHandler:
|
||||
return _NamedHandler(name)
|
||||
|
||||
|
||||
def _handler_map() -> defaultdict[str, _NamedHandler]:
|
||||
return defaultdict(lambda: _NamedHandler("unknown"))
|
||||
|
||||
|
||||
def _normalize_specs(specs: Any) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"method": spec.method,
|
||||
"path": spec.path,
|
||||
"handler": getattr(spec.handler, "__name__", type(spec.handler).__name__),
|
||||
}
|
||||
for spec in specs
|
||||
]
|
||||
|
||||
|
||||
def _metadata(handler: Any) -> dict[str, Any]:
|
||||
from services.endpoint_manifest import get_metadata
|
||||
|
||||
meta = get_metadata(handler)
|
||||
if meta is None:
|
||||
raise RuntimeError(f"missing endpoint metadata for {handler.__name__}")
|
||||
return {
|
||||
"auth": meta.auth_tier.value,
|
||||
"risk": meta.risk_tier.value,
|
||||
"plane": meta.route_plane.value if meta.route_plane else None,
|
||||
"summary": meta.summary,
|
||||
"description": meta.description,
|
||||
"audit": meta.audit_action,
|
||||
"scopes": list(meta.required_scopes),
|
||||
}
|
||||
|
||||
|
||||
def build_contract() -> dict[str, Any]:
|
||||
from api import routes
|
||||
from api.route_registrars import (
|
||||
build_assist_route_specs,
|
||||
build_connector_installation_route_specs,
|
||||
build_core_route_specs,
|
||||
build_pack_route_specs,
|
||||
)
|
||||
|
||||
core_handlers = _handler_map()
|
||||
core_keys = inspect.getsource(build_core_route_specs)
|
||||
for key in {part.split('"', 1)[0] for part in core_keys.split('handlers["')[1:]}:
|
||||
core_handlers[key] = _NamedHandler(key)
|
||||
|
||||
connector_handlers = _handler_map()
|
||||
connector_keys = inspect.getsource(build_connector_installation_route_specs)
|
||||
for key in {
|
||||
part.split('"', 1)[0] for part in connector_keys.split('handlers["')[1:]
|
||||
}:
|
||||
connector_handlers[key] = _NamedHandler(key)
|
||||
|
||||
packs = _AttributeHandlers()
|
||||
assist = _AttributeHandlers()
|
||||
families: dict[str, list[dict[str, str]]] = {}
|
||||
for prefix in ("/openclaw", "/moltbot"):
|
||||
families[f"core:{prefix}"] = _normalize_specs(
|
||||
build_core_route_specs(prefix, core_handlers)
|
||||
)
|
||||
families[f"assist:{prefix}"] = _normalize_specs(
|
||||
build_assist_route_specs(prefix, assist)
|
||||
)
|
||||
families[f"connector_installations:{prefix}"] = _normalize_specs(
|
||||
build_connector_installation_route_specs(prefix, connector_handlers)
|
||||
)
|
||||
families[f"packs:{prefix}"] = _normalize_specs(
|
||||
build_pack_route_specs(prefix, packs)
|
||||
)
|
||||
|
||||
facade_names = (
|
||||
"health_handler",
|
||||
"_ensure_observability_deps_ready",
|
||||
"logs_tail_handler",
|
||||
"jobs_handler",
|
||||
"_emit_jobs_list_audit",
|
||||
"trace_handler",
|
||||
"register_dual_route",
|
||||
"_resolve_mae_profile",
|
||||
"_run_mae_startup_gate",
|
||||
"register_routes",
|
||||
)
|
||||
facade = {
|
||||
name: str(inspect.signature(getattr(routes, name))) for name in facade_names
|
||||
}
|
||||
metadata = {
|
||||
name: _metadata(getattr(routes, name))
|
||||
for name in (
|
||||
"health_handler",
|
||||
"logs_tail_handler",
|
||||
"jobs_handler",
|
||||
"trace_handler",
|
||||
)
|
||||
}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"registration_order": [
|
||||
"startup_profile_gate",
|
||||
"core:/openclaw",
|
||||
"core:/moltbot",
|
||||
"assist:/openclaw",
|
||||
"assist:/moltbot",
|
||||
"connector_installations:/openclaw",
|
||||
"connector_installations:/moltbot",
|
||||
"bridge",
|
||||
"mae_posture_gate",
|
||||
"packs:/openclaw",
|
||||
"packs:/moltbot",
|
||||
],
|
||||
"feature_conditions": {
|
||||
"assist": "assist is truthy",
|
||||
"connector_installations": "list handler is truthy",
|
||||
"bridge": "server.app exists and BRIDGE module is enabled",
|
||||
"packs": "optional pack imports succeed",
|
||||
},
|
||||
"direct_alias_rule": "each registered path also attempts path and /api+path",
|
||||
"legacy_rule": "moltbot handlers retain telemetry and deprecation headers",
|
||||
"families": families,
|
||||
"facade_signatures": facade,
|
||||
"facade_metadata": metadata,
|
||||
"openapi_sha256": stable_text_digest(ROOT / "docs" / "openapi.yaml"),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--write-baseline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
actual = build_contract()
|
||||
if args.write_baseline:
|
||||
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
|
||||
print(f"API-ROUTE-CONTRACT-WRITTEN: {CONTRACT_PATH}")
|
||||
return 0
|
||||
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
|
||||
if actual != expected:
|
||||
print("API-ROUTE-CONTRACT-FAIL: frozen route/facade contract drifted")
|
||||
return 1
|
||||
print("API-ROUTE-CONTRACT-PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Verify the frozen R222 CommandRouter facade and command contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
|
||||
|
||||
CONTRACT_PATH = ROOT / "tests" / "connector_router_contract_r222.json"
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _command_table() -> list[dict[str, Any]]:
|
||||
from connector.router import CommandRouter
|
||||
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(CommandRouter.handle)))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id == "handlers"
|
||||
for target in node.targets
|
||||
):
|
||||
if not isinstance(node.value, ast.Dict):
|
||||
break
|
||||
entries = []
|
||||
for key, value in zip(node.value.keys, node.value.values, strict=True):
|
||||
if not isinstance(key, ast.Tuple) or not isinstance(value, ast.Tuple):
|
||||
raise RuntimeError("invalid command table entry")
|
||||
aliases = [ast.literal_eval(item) for item in key.elts]
|
||||
handler_attr = value.elts[0]
|
||||
command_class = value.elts[1]
|
||||
if not isinstance(handler_attr, ast.Attribute) or not isinstance(
|
||||
command_class, ast.Attribute
|
||||
):
|
||||
raise RuntimeError("invalid command table target")
|
||||
entries.append(
|
||||
{
|
||||
"aliases": aliases,
|
||||
"handler": handler_attr.attr,
|
||||
"class": command_class.attr,
|
||||
}
|
||||
)
|
||||
return entries
|
||||
raise RuntimeError("CommandRouter.handle command table not found")
|
||||
|
||||
|
||||
def build_contract() -> dict[str, Any]:
|
||||
from connector.router import CommandRouter
|
||||
|
||||
method_names = sorted(
|
||||
{
|
||||
name
|
||||
for owner in CommandRouter.__mro__
|
||||
if owner is not object
|
||||
for name, value in owner.__dict__.items()
|
||||
if callable(value)
|
||||
and (name == "handle" or name.startswith("_"))
|
||||
and name != "_build_llm_client"
|
||||
}
|
||||
)
|
||||
digests = {}
|
||||
for filename in (
|
||||
"api_config_contract_r221.json",
|
||||
"api_route_contract_r220.json",
|
||||
):
|
||||
path = ROOT / "tests" / filename
|
||||
digests[filename] = stable_text_digest(path)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"constructor_signature": str(inspect.signature(CommandRouter)),
|
||||
"facade_signatures": {
|
||||
name: str(inspect.signature(getattr(CommandRouter, name)))
|
||||
for name in method_names
|
||||
},
|
||||
"command_table": _command_table(),
|
||||
"instance_ownership": [
|
||||
"config",
|
||||
"client",
|
||||
"poller",
|
||||
"state",
|
||||
"_template_meta_cache",
|
||||
"_rate_limiter",
|
||||
"semantic_guard",
|
||||
"command_firewall",
|
||||
],
|
||||
"response_matrix_owners": [
|
||||
"tests.connector.test_router_hotspot_r181",
|
||||
"tests.connector.test_r214_jobs_command",
|
||||
"tests.connector.test_router_admin",
|
||||
"tests.connector.test_router_command_authz_r80",
|
||||
"tests.connector.test_security",
|
||||
"tests.connector.test_chat",
|
||||
"tests.connector.test_chat_integration",
|
||||
"tests.connector.test_media_delivery",
|
||||
"tests.chat_connector.test_router_phase2",
|
||||
"tests.chat_connector.test_router_phase3",
|
||||
],
|
||||
"upstream_contract_digests": digests,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--write-baseline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
actual = build_contract()
|
||||
if args.write_baseline:
|
||||
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
|
||||
print(f"CONNECTOR-ROUTER-CONTRACT-WRITTEN: {CONTRACT_PATH}")
|
||||
return 0
|
||||
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
|
||||
if actual != expected:
|
||||
print("CONNECTOR-ROUTER-CONTRACT-FAIL")
|
||||
return 1
|
||||
print("CONNECTOR-ROUTER-CONTRACT-PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -12,6 +12,7 @@ import ast
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -20,6 +21,7 @@ VALID_CLASSIFICATIONS = {
|
||||
"needs_narrowing",
|
||||
"needs_follow_up_test_coverage",
|
||||
}
|
||||
VALID_COVERAGE_MODES = {"all_broad_catches", "selected_scopes"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -93,11 +95,26 @@ def validate_exception_boundary_policy(
|
||||
policy: dict[str, Any],
|
||||
) -> list[str]:
|
||||
failures: list[str] = []
|
||||
if set(policy) != {"version", "selected_modules"}:
|
||||
failures.append("policy root keys must match the version 2 schema")
|
||||
if policy.get("version") != 2:
|
||||
failures.append("policy version must equal 2")
|
||||
modules = policy.get("selected_modules")
|
||||
if not isinstance(modules, dict) or not modules:
|
||||
return ["policy selected_modules must be a non-empty object"]
|
||||
|
||||
for rel_path, module_policy in sorted(modules.items()):
|
||||
rel_path_obj = Path(rel_path)
|
||||
if (
|
||||
rel_path_obj.is_absolute()
|
||||
or ".." in rel_path_obj.parts
|
||||
or rel_path_obj.suffix != ".py"
|
||||
):
|
||||
failures.append(f"{rel_path}: unsafe selected module path")
|
||||
continue
|
||||
if not isinstance(module_policy, dict):
|
||||
failures.append(f"{rel_path}: module policy must be an object")
|
||||
continue
|
||||
path = repo_root / rel_path
|
||||
if not path.is_file():
|
||||
failures.append(f"{rel_path}: selected module does not exist")
|
||||
@@ -108,14 +125,60 @@ def validate_exception_boundary_policy(
|
||||
failures.append(f"{rel_path}: broad_catches must be a list")
|
||||
continue
|
||||
|
||||
coverage = module_policy.get("coverage")
|
||||
if coverage not in VALID_COVERAGE_MODES:
|
||||
failures.append(f"{rel_path}: invalid coverage mode {coverage!r}")
|
||||
continue
|
||||
expected_module_keys = {"coverage", "broad_catches"}
|
||||
if coverage == "selected_scopes":
|
||||
expected_module_keys.add("selected_scopes")
|
||||
if set(module_policy) != expected_module_keys:
|
||||
failures.append(f"{rel_path}: module keys must match coverage schema")
|
||||
selected_scopes_raw = module_policy.get("selected_scopes", [])
|
||||
if coverage == "selected_scopes":
|
||||
if (
|
||||
not isinstance(selected_scopes_raw, list)
|
||||
or not selected_scopes_raw
|
||||
or any(
|
||||
not isinstance(scope, str) or not scope
|
||||
for scope in selected_scopes_raw
|
||||
)
|
||||
or len(selected_scopes_raw) != len(set(selected_scopes_raw))
|
||||
):
|
||||
failures.append(
|
||||
f"{rel_path}: selected_scopes must be a unique non-empty string list"
|
||||
)
|
||||
selected_scopes: set[str] = set()
|
||||
else:
|
||||
selected_scopes = set(selected_scopes_raw)
|
||||
else:
|
||||
if selected_scopes_raw:
|
||||
failures.append(
|
||||
f"{rel_path}: all_broad_catches must not declare selected_scopes"
|
||||
)
|
||||
selected_scopes = set()
|
||||
|
||||
entries_by_scope: dict[str, dict[str, Any]] = {}
|
||||
for index, entry in enumerate(allowed):
|
||||
if not isinstance(entry, dict):
|
||||
failures.append(f"{rel_path}: broad_catches[{index}] must be an object")
|
||||
continue
|
||||
if set(entry) != {
|
||||
"scope",
|
||||
"expected_count",
|
||||
"classification",
|
||||
"reason",
|
||||
"regression_owner",
|
||||
"review_after",
|
||||
}:
|
||||
failures.append(
|
||||
f"{rel_path}: broad_catches[{index}] entry keys must match schema"
|
||||
)
|
||||
scope = entry.get("scope")
|
||||
classification = entry.get("classification")
|
||||
reason = entry.get("reason")
|
||||
regression_owner = entry.get("regression_owner")
|
||||
review_after = entry.get("review_after")
|
||||
if not isinstance(scope, str) or not scope:
|
||||
failures.append(f"{rel_path}: broad_catches[{index}] missing scope")
|
||||
continue
|
||||
@@ -128,10 +191,49 @@ def validate_exception_boundary_policy(
|
||||
)
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
failures.append(f"{rel_path}:{scope}: missing reason")
|
||||
if not isinstance(regression_owner, str) or not regression_owner.strip():
|
||||
failures.append(f"{rel_path}:{scope}: missing regression_owner")
|
||||
else:
|
||||
owner_path = Path(regression_owner)
|
||||
if (
|
||||
owner_path.is_absolute()
|
||||
or ".." in owner_path.parts
|
||||
or not owner_path.parts
|
||||
or owner_path.parts[0] != "tests"
|
||||
or owner_path.suffix != ".py"
|
||||
):
|
||||
failures.append(
|
||||
f"{rel_path}:{scope}: regression_owner must be a safe tests/*.py path"
|
||||
)
|
||||
elif not (repo_root / owner_path).is_file():
|
||||
failures.append(
|
||||
f"{rel_path}:{scope}: regression_owner does not exist"
|
||||
)
|
||||
try:
|
||||
if not isinstance(review_after, str):
|
||||
raise TypeError
|
||||
review_date = date.fromisoformat(review_after)
|
||||
except (TypeError, ValueError):
|
||||
failures.append(f"{rel_path}:{scope}: invalid review_after")
|
||||
else:
|
||||
if review_date < date.today():
|
||||
failures.append(f"{rel_path}:{scope}: review_after is expired")
|
||||
if coverage == "selected_scopes" and scope not in selected_scopes:
|
||||
failures.append(f"{rel_path}:{scope}: entry is outside selected_scopes")
|
||||
|
||||
catches = tuple(iter_broad_catches(path))
|
||||
counts = Counter(catch.scope for catch in catches)
|
||||
for catch in catches:
|
||||
governed_catches = (
|
||||
catches
|
||||
if coverage == "all_broad_catches"
|
||||
else tuple(catch for catch in catches if catch.scope in selected_scopes)
|
||||
)
|
||||
counts = Counter(catch.scope for catch in governed_catches)
|
||||
if coverage == "selected_scopes":
|
||||
for stale_scope in sorted(selected_scopes - set(counts)):
|
||||
failures.append(
|
||||
f"{rel_path}:{stale_scope}: selected scope has no broad catch"
|
||||
)
|
||||
for catch in governed_catches:
|
||||
if catch.scope not in entries_by_scope:
|
||||
failures.append(
|
||||
f"{rel_path}:{catch.line}: undocumented broad catch in {catch.scope}"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** Verify the frozen R224 Settings/API frontend contract. */
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stableTextDigest } from "./contract_digest.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const CONTRACT_PATH = path.join(ROOT, "web", "tests", "fixtures", "frontend_decomposition_contract_r224.json");
|
||||
|
||||
function canonicalJson(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function familySources(directory, prefix) {
|
||||
return fs.readdirSync(path.join(ROOT, directory))
|
||||
.filter((name) => name === `${prefix}.js` || name.startsWith(`${prefix}_`))
|
||||
.sort()
|
||||
.map((name) => read(path.join(directory, name)))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
function matches(source, pattern, group = 1) {
|
||||
return [...source.matchAll(pattern)].map((match) => match[group]);
|
||||
}
|
||||
|
||||
function methodSignatures(source) {
|
||||
const result = {};
|
||||
const pattern = /^\s{4}(?:async\s+)?([A-Za-z_$][\w$]*)\(([^)]*)\)\s*\{/gm;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
result[match[1]] = match[2].replace(/\s+/g, " ").trim();
|
||||
}
|
||||
return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function digest(relativePath) {
|
||||
return stableTextDigest(path.join(ROOT, relativePath));
|
||||
}
|
||||
|
||||
export function buildContract() {
|
||||
const apiFacade = read("web/openclaw_api.js");
|
||||
const settingsFacade = read("web/tabs/settings_tab.js");
|
||||
const apiSources = familySources("web", "openclaw_api");
|
||||
const settingsSources = familySources("web/tabs", "settings_tab");
|
||||
return {
|
||||
schema_version: 1,
|
||||
api: {
|
||||
exports: matches(apiFacade, /^export\s+(?:class|const)\s+([A-Za-z_$][\w$]*)/gm),
|
||||
methods: methodSignatures(apiSources),
|
||||
constructor_state: uniqueSorted(matches(
|
||||
apiFacade,
|
||||
/^\s{8}this\.([A-Za-z_$][\w$]*)\s*=/gm,
|
||||
)),
|
||||
path_suffixes: uniqueSorted(matches(apiSources, /this\._path\("([^"]+)"\)/g)),
|
||||
compatibility_seams: [
|
||||
"fetch",
|
||||
"_fetchWithCandidates",
|
||||
"_capabilitiesCache",
|
||||
"_capabilitiesCacheTs",
|
||||
"streamSSEPost",
|
||||
"subscribeEvents",
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
exports: matches(settingsFacade, /^export\s+const\s+([A-Za-z_$][\w$]*)/gm),
|
||||
identity: {
|
||||
id: settingsFacade.match(/\bid:\s*"([^"]+)"/)?.[1] || "",
|
||||
title: settingsFacade.match(/\btitle:\s*"([^"]+)"/)?.[1] || "",
|
||||
icon: settingsFacade.match(/\bicon:\s*"([^"]+)"/)?.[1] || "",
|
||||
},
|
||||
dom_ids: uniqueSorted(matches(settingsSources, /id="(openclaw-[^"]+)"/g)),
|
||||
class_tokens: uniqueSorted(matches(settingsSources, /\b(openclaw-[a-z0-9-]+)\b/g)),
|
||||
section_headings: uniqueSorted(matches(
|
||||
settingsSources,
|
||||
/create(?:Collapsible)?Section\("([^"]+)"/g,
|
||||
)),
|
||||
compatibility_seams: ["settingsTab", "settingsTab.render"],
|
||||
},
|
||||
upstream_contract_digests: {
|
||||
"tests/api_route_contract_r220.json": digest("tests/api_route_contract_r220.json"),
|
||||
"tests/api_config_contract_r221.json": digest("tests/api_config_contract_r221.json"),
|
||||
"tests/platform_adapter_contract_r223.json": digest("tests/platform_adapter_contract_r223.json"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyContract({ writeBaseline = false } = {}) {
|
||||
const actual = buildContract();
|
||||
if (writeBaseline) {
|
||||
fs.mkdirSync(path.dirname(CONTRACT_PATH), { recursive: true });
|
||||
fs.writeFileSync(CONTRACT_PATH, canonicalJson(actual), "utf8");
|
||||
return { ok: true, message: `FRONTEND-CONTRACT-WRITTEN:${CONTRACT_PATH}` };
|
||||
}
|
||||
const expected = JSON.parse(fs.readFileSync(CONTRACT_PATH, "utf8"));
|
||||
const ok = canonicalJson(actual) === canonicalJson(expected);
|
||||
return { ok, message: ok ? "FRONTEND-CONTRACT-PASS" : "FRONTEND-CONTRACT-FAIL" };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const result = verifyContract({ writeBaseline: process.argv.includes("--write-baseline") });
|
||||
console.log(result.message);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Verify the frozen R223 Slack and Feishu adapter contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
|
||||
|
||||
CONTRACT_PATH = ROOT / "tests" / "platform_adapter_contract_r223.json"
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _method_signatures(owner: type) -> dict[str, str]:
|
||||
names = {
|
||||
name
|
||||
for cls in owner.__mro__
|
||||
if cls is not object
|
||||
for name, value in cls.__dict__.items()
|
||||
if callable(value)
|
||||
and (not name.startswith("__") or name == "__init__")
|
||||
and not name.startswith("_adapter_")
|
||||
}
|
||||
return {
|
||||
name: str(inspect.signature(getattr(owner, name))) for name in sorted(names)
|
||||
}
|
||||
|
||||
|
||||
def _instance_ownership(owner: type) -> list[str]:
|
||||
tree = ast.parse(textwrap.dedent(inspect.getsource(owner.__init__))) # type: ignore[misc]
|
||||
names = set()
|
||||
for node in ast.walk(tree):
|
||||
target = None
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
||||
target = node.targets[0]
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
target = node.target
|
||||
if (
|
||||
isinstance(target, ast.Attribute)
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id == "self"
|
||||
):
|
||||
names.add(target.attr)
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def build_contract() -> dict[str, Any]:
|
||||
from connector.platforms.feishu_webhook import (
|
||||
FEISHU_DOMAIN_BASES,
|
||||
FEISHU_TOKEN_TTL_SEC,
|
||||
FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
||||
FeishuDeliveryTarget,
|
||||
FeishuWebhookServer,
|
||||
)
|
||||
from connector.platforms.slack_webhook import (
|
||||
SLACK_SIGNING_VERSION,
|
||||
SLACK_TIMESTAMP_MAX_DRIFT_SEC,
|
||||
SlackWebhookServer,
|
||||
)
|
||||
|
||||
router_contract = ROOT / "tests" / "connector_router_contract_r222.json"
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"slack": {
|
||||
"class_constants": {
|
||||
"REPLAY_WINDOW_SEC": SlackWebhookServer.REPLAY_WINDOW_SEC,
|
||||
"NONCE_CACHE_SIZE": SlackWebhookServer.NONCE_CACHE_SIZE,
|
||||
"SLACK_SIGNING_VERSION": SLACK_SIGNING_VERSION,
|
||||
"SLACK_TIMESTAMP_MAX_DRIFT_SEC": SLACK_TIMESTAMP_MAX_DRIFT_SEC,
|
||||
},
|
||||
"method_signatures": _method_signatures(SlackWebhookServer),
|
||||
"instance_ownership": _instance_ownership(SlackWebhookServer),
|
||||
"routes": [
|
||||
["POST", "slack_webhook_path", "handle_event"],
|
||||
["POST", "slack_interactions_path", "handle_interaction"],
|
||||
["GET", "slack_oauth_install_path", "handle_oauth_install"],
|
||||
["GET", "slack_oauth_callback_path", "handle_oauth_callback"],
|
||||
],
|
||||
"patch_seams": [
|
||||
"_import_aiohttp_web",
|
||||
"_make_response",
|
||||
"_make_json_response",
|
||||
"_make_redirect_response",
|
||||
"verify_slack_signature",
|
||||
"logger",
|
||||
],
|
||||
},
|
||||
"feishu": {
|
||||
"class_constants": {
|
||||
"REPLAY_WINDOW_SEC": FeishuWebhookServer.REPLAY_WINDOW_SEC,
|
||||
"NONCE_CACHE_SIZE": FeishuWebhookServer.NONCE_CACHE_SIZE,
|
||||
"FEISHU_WEBHOOK_MAX_BODY_BYTES": FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
||||
"FEISHU_TOKEN_TTL_SEC": FEISHU_TOKEN_TTL_SEC,
|
||||
"FEISHU_DOMAIN_BASES": FEISHU_DOMAIN_BASES,
|
||||
},
|
||||
"method_signatures": _method_signatures(FeishuWebhookServer),
|
||||
"delivery_target_signature": str(inspect.signature(FeishuDeliveryTarget)),
|
||||
"instance_ownership": _instance_ownership(FeishuWebhookServer),
|
||||
"routes": [
|
||||
["POST", "feishu_webhook_path", "handle_event"],
|
||||
["POST", "feishu_callback_path", "handle_callback"],
|
||||
],
|
||||
"patch_seams": [
|
||||
"_import_aiohttp_web",
|
||||
"_make_response",
|
||||
"_make_json_response",
|
||||
"safe_request_json",
|
||||
"logger",
|
||||
],
|
||||
},
|
||||
"response_matrix_owners": [
|
||||
"tests.test_r124_slack_ingress_contract",
|
||||
"tests.test_r125_slack_real_backend_lane",
|
||||
"tests.test_f57_slack_transport_parity",
|
||||
"tests.test_f58_slack_oauth_installations",
|
||||
"tests.test_f59_slack_interactions",
|
||||
"tests.test_f67_feishu_transport_parity",
|
||||
"tests.test_f68_feishu_installations",
|
||||
"tests.test_f69_feishu_callbacks",
|
||||
"tests.test_f74_reply_visibility_policy",
|
||||
"tests.security.test_s80_connector_ingress",
|
||||
],
|
||||
"router_contract_digest": stable_text_digest(router_contract),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--write-baseline", action="store_true")
|
||||
args = parser.parse_args()
|
||||
actual = build_contract()
|
||||
if args.write_baseline:
|
||||
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
|
||||
print(f"PLATFORM-ADAPTER-CONTRACT-WRITTEN: {CONTRACT_PATH}")
|
||||
return 0
|
||||
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
|
||||
if actual != expected:
|
||||
print("PLATFORM-ADAPTER-CONTRACT-FAIL")
|
||||
return 1
|
||||
print("PLATFORM-ADAPTER-CONTRACT-PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,933 @@
|
||||
"""Verify the repository's source-level production dependency contract.
|
||||
|
||||
The verifier deliberately uses only Git metadata and Python's standard-library
|
||||
parser. It never imports analyzed modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tokenize
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
POLICY_PATH = "tests/architecture_dependency_policy.json"
|
||||
MAX_FINDINGS = 50
|
||||
_TOP_LEVEL_KEYS = {
|
||||
"schema_version",
|
||||
"review",
|
||||
"tracked_roots",
|
||||
"domains",
|
||||
"allowed_dependencies",
|
||||
"compatibility_exceptions",
|
||||
"facade_contracts",
|
||||
"accepted_cycles",
|
||||
"dynamic_imports",
|
||||
}
|
||||
_REVIEW_KEYS = {
|
||||
"owner",
|
||||
"reviewed_at",
|
||||
"next_review_by",
|
||||
"static_analysis_policy_schema",
|
||||
}
|
||||
_EXCEPTION_KEYS = {
|
||||
"importer",
|
||||
"imported",
|
||||
"owner",
|
||||
"rationale",
|
||||
"review_condition",
|
||||
}
|
||||
_FACADE_KEYS = {
|
||||
"facade",
|
||||
"implementation",
|
||||
"owner",
|
||||
"rationale",
|
||||
"review_condition",
|
||||
}
|
||||
_CYCLE_KEYS = {"modules", "owner", "rationale", "review_condition"}
|
||||
_DYNAMIC_KEYS = {
|
||||
"path",
|
||||
"scope",
|
||||
"callee",
|
||||
"target_kind",
|
||||
"target",
|
||||
"owner",
|
||||
"rationale",
|
||||
"review_condition",
|
||||
}
|
||||
_METADATA_KEYS = ("owner", "rationale", "review_condition")
|
||||
_DOMAIN_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Finding:
|
||||
"""A deterministic, content-free policy finding."""
|
||||
|
||||
rule_id: str
|
||||
path: str
|
||||
line: int = 0
|
||||
subject: str = ""
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
"""Compatibility alias for callers using diagnostic terminology."""
|
||||
|
||||
return self.rule_id
|
||||
|
||||
@property
|
||||
def identity(self) -> str:
|
||||
"""Return the bounded identity without exposing source content."""
|
||||
|
||||
return self.subject
|
||||
|
||||
def render(self) -> str:
|
||||
return render_findings((self,))
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class DynamicImport:
|
||||
path: str
|
||||
scope: str
|
||||
callee: str
|
||||
target_kind: str
|
||||
target: str
|
||||
line: int = 0
|
||||
|
||||
@property
|
||||
def identity(self) -> tuple[str, str, str, str, str]:
|
||||
return (
|
||||
self.path,
|
||||
self.scope,
|
||||
self.callee,
|
||||
self.target_kind,
|
||||
self.target,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Analysis:
|
||||
owned_paths: tuple[str, ...]
|
||||
static_edges: tuple[tuple[str, str], ...]
|
||||
dynamic_imports: tuple[DynamicImport, ...]
|
||||
cycles: tuple[tuple[str, ...], ...]
|
||||
findings: tuple[Finding, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PolicyContext:
|
||||
tracked_files: set[str]
|
||||
owned_paths: set[str]
|
||||
path_domains: dict[str, str]
|
||||
path_modules: dict[str, str]
|
||||
module_paths: dict[str, str]
|
||||
allowed_dependencies: dict[str, set[str]]
|
||||
compatibility_exceptions: set[tuple[str, str]]
|
||||
facade_contracts: set[tuple[str, str]]
|
||||
accepted_cycles: set[frozenset[str]]
|
||||
dynamic_imports: dict[tuple[str, str, str, str, str], Mapping[str, Any]]
|
||||
|
||||
|
||||
def _finding(
|
||||
rule_id: str, path: str = ".", *, line: int = 0, subject: str = ""
|
||||
) -> Finding:
|
||||
return Finding(rule_id=rule_id, path=path, line=line, subject=subject)
|
||||
|
||||
|
||||
def _safe_relative_path(value: Any) -> bool:
|
||||
if not isinstance(value, str) or not value or "\\" in value:
|
||||
return False
|
||||
if re.match(r"^[A-Za-z]:", value):
|
||||
return False
|
||||
path = PurePosixPath(value)
|
||||
return (
|
||||
not path.is_absolute()
|
||||
and value == path.as_posix()
|
||||
and "." not in path.parts
|
||||
and ".." not in path.parts
|
||||
)
|
||||
|
||||
|
||||
def _within_root(path: str, root: str) -> bool:
|
||||
return path == root or path.startswith(f"{root.rstrip('/')}/")
|
||||
|
||||
|
||||
def _module_name(path: str) -> str:
|
||||
parts = list(PurePosixPath(path).with_suffix("").parts)
|
||||
if parts[-1] == "__init__":
|
||||
parts.pop()
|
||||
return ".".join(parts) or "__init__"
|
||||
|
||||
|
||||
def _tracked_python_files(repo_root: Path) -> tuple[set[str], list[Finding]]:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--", "*.py"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return set(), [_finding("TRACKED_DISCOVERY_FAILED")]
|
||||
return (
|
||||
{
|
||||
line.strip().replace("\\", "/")
|
||||
for line in result.stdout.splitlines()
|
||||
if line.strip()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
def _validate_review_metadata(
|
||||
entry: Mapping[str, Any],
|
||||
*,
|
||||
path: str,
|
||||
findings: list[Finding],
|
||||
) -> None:
|
||||
if any(
|
||||
not isinstance(entry.get(key), str) or not str(entry.get(key)).strip()
|
||||
for key in _METADATA_KEYS
|
||||
):
|
||||
findings.append(_finding("POLICY_REVIEW_METADATA", subject=path))
|
||||
|
||||
|
||||
def _validate_policy(
|
||||
repo_root: Path,
|
||||
policy: Mapping[str, Any],
|
||||
tracked_files: Iterable[str] | None,
|
||||
) -> tuple[_PolicyContext, list[Finding]]:
|
||||
findings: list[Finding] = []
|
||||
unknown_keys = set(policy) - _TOP_LEVEL_KEYS
|
||||
for key in sorted(unknown_keys):
|
||||
findings.append(_finding("POLICY_UNKNOWN_KEY", subject=key))
|
||||
if policy.get("schema_version") != 1:
|
||||
findings.append(_finding("POLICY_SCHEMA_VERSION"))
|
||||
|
||||
review = policy.get("review")
|
||||
if not isinstance(review, Mapping):
|
||||
findings.append(_finding("POLICY_REVIEW_METADATA", subject="review"))
|
||||
else:
|
||||
for key in sorted(set(review) - _REVIEW_KEYS):
|
||||
findings.append(_finding("POLICY_UNKNOWN_KEY", subject=f"review.{key}"))
|
||||
if not isinstance(review.get("owner"), str) or not review["owner"].strip():
|
||||
findings.append(_finding("POLICY_REVIEW_METADATA", subject="review.owner"))
|
||||
parsed_dates: dict[str, date] = {}
|
||||
for key in ("reviewed_at", "next_review_by"):
|
||||
try:
|
||||
parsed_dates[key] = date.fromisoformat(str(review.get(key, "")))
|
||||
except ValueError:
|
||||
findings.append(
|
||||
_finding("POLICY_REVIEW_METADATA", subject=f"review.{key}")
|
||||
)
|
||||
if (
|
||||
len(parsed_dates) == 2
|
||||
and parsed_dates["next_review_by"] < parsed_dates["reviewed_at"]
|
||||
):
|
||||
findings.append(
|
||||
_finding("POLICY_REVIEW_METADATA", subject="review.date_order")
|
||||
)
|
||||
|
||||
if tracked_files is None:
|
||||
discovered, discovery_findings = _tracked_python_files(repo_root)
|
||||
findings.extend(discovery_findings)
|
||||
else:
|
||||
discovered = {
|
||||
str(path).replace("\\", "/")
|
||||
for path in tracked_files
|
||||
if str(path).endswith(".py")
|
||||
}
|
||||
|
||||
roots_value = policy.get("tracked_roots")
|
||||
roots = roots_value if isinstance(roots_value, list) else []
|
||||
if not isinstance(roots_value, list) or not roots:
|
||||
findings.append(_finding("ROOTS_INVALID"))
|
||||
valid_roots: list[str] = []
|
||||
seen_roots: set[str] = set()
|
||||
for index, value in enumerate(roots):
|
||||
subject = f"tracked_roots[{index}]"
|
||||
if not _safe_relative_path(value):
|
||||
findings.append(_finding("PATH_UNSAFE", subject=subject))
|
||||
continue
|
||||
root = str(value)
|
||||
if root in seen_roots:
|
||||
findings.append(_finding("ROOT_DUPLICATE", path=root))
|
||||
continue
|
||||
seen_roots.add(root)
|
||||
valid_roots.append(root)
|
||||
candidate = repo_root / root
|
||||
try:
|
||||
candidate.resolve().relative_to(repo_root.resolve())
|
||||
except ValueError:
|
||||
findings.append(_finding("PATH_UNSAFE", path=root))
|
||||
continue
|
||||
if not candidate.exists():
|
||||
findings.append(_finding("ROOT_MISSING", path=root))
|
||||
|
||||
domains_value = policy.get("domains")
|
||||
domains = domains_value if isinstance(domains_value, Mapping) else {}
|
||||
if not domains:
|
||||
findings.append(_finding("DOMAINS_INVALID"))
|
||||
valid_domain_names = {
|
||||
str(name)
|
||||
for name in domains
|
||||
if isinstance(name, str) and _DOMAIN_RE.fullmatch(name)
|
||||
}
|
||||
for name in domains:
|
||||
if name not in valid_domain_names:
|
||||
findings.append(_finding("DOMAIN_UNKNOWN", subject=str(name)))
|
||||
|
||||
owned_paths: set[str] = set()
|
||||
path_domains: dict[str, str] = {}
|
||||
path_modules: dict[str, str] = {}
|
||||
module_paths: dict[str, str] = {}
|
||||
for domain_name, entries in domains.items():
|
||||
if domain_name not in valid_domain_names:
|
||||
continue
|
||||
if not isinstance(entries, list):
|
||||
findings.append(_finding("OWNERSHIP_INVALID", subject=str(domain_name)))
|
||||
continue
|
||||
for index, value in enumerate(entries):
|
||||
subject = f"domains.{domain_name}[{index}]"
|
||||
if not _safe_relative_path(value):
|
||||
findings.append(_finding("PATH_UNSAFE", subject=subject))
|
||||
continue
|
||||
path = str(value)
|
||||
if not path.endswith(".py"):
|
||||
findings.append(_finding("OWNERSHIP_INVALID", path=path))
|
||||
continue
|
||||
if path in path_domains:
|
||||
findings.append(_finding("OWN_DUPLICATE", path=path))
|
||||
continue
|
||||
path_domains[path] = str(domain_name)
|
||||
owned_paths.add(path)
|
||||
if not any(_within_root(path, root) for root in valid_roots):
|
||||
findings.append(_finding("OWN_OUTSIDE_ROOT", path=path))
|
||||
if path not in discovered:
|
||||
findings.append(_finding("OWN_NOT_TRACKED", path=path))
|
||||
candidate = repo_root / path
|
||||
try:
|
||||
candidate.resolve().relative_to(repo_root.resolve())
|
||||
except ValueError:
|
||||
findings.append(_finding("PATH_UNSAFE", path=path))
|
||||
continue
|
||||
if not candidate.is_file():
|
||||
findings.append(_finding("OWN_MISSING", path=path))
|
||||
module = _module_name(path)
|
||||
if module in module_paths:
|
||||
findings.append(
|
||||
_finding("OWN_MODULE_COLLISION", path=path, subject=module)
|
||||
)
|
||||
else:
|
||||
path_modules[path] = module
|
||||
module_paths[module] = path
|
||||
|
||||
tracked_in_roots = {
|
||||
path
|
||||
for path in discovered
|
||||
if any(_within_root(path, root) for root in valid_roots)
|
||||
}
|
||||
for path in sorted(tracked_in_roots - owned_paths):
|
||||
findings.append(_finding("OWN_UNOWNED_MODULE", path=path))
|
||||
|
||||
allowed_value = policy.get("allowed_dependencies")
|
||||
allowed_raw = allowed_value if isinstance(allowed_value, Mapping) else {}
|
||||
if not isinstance(allowed_value, Mapping):
|
||||
findings.append(_finding("DEPENDENCIES_INVALID"))
|
||||
for domain in sorted(valid_domain_names - set(allowed_raw)):
|
||||
findings.append(_finding("DOMAIN_DIRECTION_MISSING", subject=domain))
|
||||
for domain in sorted(set(allowed_raw) - valid_domain_names):
|
||||
findings.append(_finding("DOMAIN_UNKNOWN", subject=str(domain)))
|
||||
allowed_dependencies: dict[str, set[str]] = {}
|
||||
for domain in sorted(valid_domain_names):
|
||||
values = allowed_raw.get(domain, [])
|
||||
if not isinstance(values, list):
|
||||
findings.append(_finding("DEPENDENCIES_INVALID", subject=domain))
|
||||
values = []
|
||||
accepted: set[str] = set()
|
||||
for target in values:
|
||||
if target not in valid_domain_names:
|
||||
findings.append(
|
||||
_finding(
|
||||
"DOMAIN_UNKNOWN",
|
||||
subject=f"{domain}->{target}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
accepted.add(str(target))
|
||||
allowed_dependencies[domain] = accepted
|
||||
|
||||
compatibility_exceptions: set[tuple[str, str]] = set()
|
||||
exception_entries = policy.get("compatibility_exceptions")
|
||||
if not isinstance(exception_entries, list):
|
||||
findings.append(_finding("EXCEPTIONS_INVALID"))
|
||||
exception_entries = []
|
||||
for index, entry in enumerate(exception_entries):
|
||||
subject = f"compatibility_exceptions[{index}]"
|
||||
if not isinstance(entry, Mapping):
|
||||
findings.append(_finding("EXCEPTIONS_INVALID", subject=subject))
|
||||
continue
|
||||
for key in sorted(set(entry) - _EXCEPTION_KEYS):
|
||||
findings.append(_finding("POLICY_UNKNOWN_KEY", subject=f"{subject}.{key}"))
|
||||
_validate_review_metadata(entry, path=subject, findings=findings)
|
||||
edge = (str(entry.get("importer", "")), str(entry.get("imported", "")))
|
||||
if edge in compatibility_exceptions:
|
||||
findings.append(_finding("DEP_DUPLICATE_EXCEPTION", subject=subject))
|
||||
compatibility_exceptions.add(edge)
|
||||
if edge[0] not in module_paths or edge[1] not in module_paths:
|
||||
findings.append(_finding("DEP_EXCEPTION_MODULE_UNKNOWN", subject=subject))
|
||||
|
||||
facade_contracts: set[tuple[str, str]] = set()
|
||||
facade_entries = policy.get("facade_contracts", [])
|
||||
if not isinstance(facade_entries, list):
|
||||
findings.append(_finding("FACADES_INVALID"))
|
||||
facade_entries = []
|
||||
for index, entry in enumerate(facade_entries):
|
||||
subject = f"facade_contracts[{index}]"
|
||||
if not isinstance(entry, Mapping):
|
||||
findings.append(_finding("FACADES_INVALID", subject=subject))
|
||||
continue
|
||||
for key in sorted(set(entry) - _FACADE_KEYS):
|
||||
findings.append(_finding("POLICY_UNKNOWN_KEY", subject=f"{subject}.{key}"))
|
||||
_validate_review_metadata(entry, path=subject, findings=findings)
|
||||
edge = (
|
||||
str(entry.get("facade", "")),
|
||||
str(entry.get("implementation", "")),
|
||||
)
|
||||
if not edge[0] or not edge[1] or edge[0] == edge[1]:
|
||||
findings.append(_finding("FACADES_INVALID", subject=subject))
|
||||
continue
|
||||
if edge in facade_contracts:
|
||||
findings.append(_finding("FACADE_DUPLICATE", subject=subject))
|
||||
facade_contracts.add(edge)
|
||||
if edge[0] not in module_paths or edge[1] not in module_paths:
|
||||
findings.append(_finding("FACADE_MODULE_UNKNOWN", subject=subject))
|
||||
|
||||
accepted_cycles: set[frozenset[str]] = set()
|
||||
cycle_entries = policy.get("accepted_cycles")
|
||||
if not isinstance(cycle_entries, list):
|
||||
findings.append(_finding("CYCLES_INVALID"))
|
||||
cycle_entries = []
|
||||
for index, entry in enumerate(cycle_entries):
|
||||
subject = f"accepted_cycles[{index}]"
|
||||
if not isinstance(entry, Mapping):
|
||||
findings.append(_finding("CYCLES_INVALID", subject=subject))
|
||||
continue
|
||||
for key in sorted(set(entry) - _CYCLE_KEYS):
|
||||
findings.append(_finding("POLICY_UNKNOWN_KEY", subject=f"{subject}.{key}"))
|
||||
_validate_review_metadata(entry, path=subject, findings=findings)
|
||||
modules = entry.get("modules")
|
||||
if (
|
||||
not isinstance(modules, list)
|
||||
or len(modules) < 2
|
||||
or any(not isinstance(module, str) for module in modules)
|
||||
):
|
||||
findings.append(_finding("CYCLES_INVALID", subject=subject))
|
||||
continue
|
||||
cycle = frozenset(modules)
|
||||
if len(cycle) != len(modules):
|
||||
findings.append(_finding("CYCLE_DUPLICATE_MODULE", subject=subject))
|
||||
if cycle in accepted_cycles:
|
||||
findings.append(_finding("CYCLE_DUPLICATE_BASELINE", subject=subject))
|
||||
accepted_cycles.add(cycle)
|
||||
if any(module not in module_paths for module in cycle):
|
||||
findings.append(_finding("CYCLE_MODULE_UNKNOWN", subject=subject))
|
||||
|
||||
dynamic_imports: dict[tuple[str, str, str, str, str], Mapping[str, Any]] = {}
|
||||
dynamic_entries = policy.get("dynamic_imports")
|
||||
if not isinstance(dynamic_entries, list):
|
||||
findings.append(_finding("DYNAMIC_INVALID"))
|
||||
dynamic_entries = []
|
||||
for index, entry in enumerate(dynamic_entries):
|
||||
subject = f"dynamic_imports[{index}]"
|
||||
if not isinstance(entry, Mapping):
|
||||
findings.append(_finding("DYNAMIC_INVALID", subject=subject))
|
||||
continue
|
||||
for key in sorted(set(entry) - _DYNAMIC_KEYS):
|
||||
findings.append(_finding("POLICY_UNKNOWN_KEY", subject=f"{subject}.{key}"))
|
||||
_validate_review_metadata(entry, path=subject, findings=findings)
|
||||
path_value = entry.get("path")
|
||||
if not _safe_relative_path(path_value):
|
||||
findings.append(_finding("PATH_UNSAFE", subject=subject))
|
||||
continue
|
||||
dynamic_path = str(path_value)
|
||||
if dynamic_path not in owned_paths:
|
||||
findings.append(_finding("DYNAMIC_PATH_UNOWNED", path=dynamic_path))
|
||||
target_kind = entry.get("target_kind")
|
||||
identity = (
|
||||
dynamic_path,
|
||||
str(entry.get("scope", "")),
|
||||
str(entry.get("callee", "")),
|
||||
str(target_kind),
|
||||
str(entry.get("target", "")),
|
||||
)
|
||||
if (
|
||||
not identity[1]
|
||||
or identity[2]
|
||||
not in {"__import__", "importlib.import_module", "import_module"}
|
||||
or target_kind not in {"literal", "expression"}
|
||||
or not identity[4]
|
||||
):
|
||||
findings.append(_finding("DYNAMIC_INVALID", path=dynamic_path))
|
||||
if identity in dynamic_imports:
|
||||
findings.append(_finding("DYNAMIC_DUPLICATE", path=dynamic_path))
|
||||
dynamic_imports[identity] = entry
|
||||
|
||||
context = _PolicyContext(
|
||||
tracked_files=discovered,
|
||||
owned_paths=owned_paths,
|
||||
path_domains=path_domains,
|
||||
path_modules=path_modules,
|
||||
module_paths=module_paths,
|
||||
allowed_dependencies=allowed_dependencies,
|
||||
compatibility_exceptions=compatibility_exceptions,
|
||||
facade_contracts=facade_contracts,
|
||||
accepted_cycles=accepted_cycles,
|
||||
dynamic_imports=dynamic_imports,
|
||||
)
|
||||
return context, findings
|
||||
|
||||
|
||||
def _resolve_relative_import(
|
||||
current_module: str,
|
||||
current_path: str,
|
||||
node: ast.ImportFrom,
|
||||
) -> str:
|
||||
if not node.level:
|
||||
return node.module or ""
|
||||
is_package = current_path.endswith("/__init__.py") or current_path == "__init__.py"
|
||||
if current_path == "__init__.py":
|
||||
package_parts: list[str] = []
|
||||
else:
|
||||
package_parts = (
|
||||
current_module.split(".") if is_package else current_module.split(".")[:-1]
|
||||
)
|
||||
ascend = node.level - 1
|
||||
if ascend > len(package_parts):
|
||||
prefix: list[str] = []
|
||||
elif ascend:
|
||||
prefix = package_parts[:-ascend]
|
||||
else:
|
||||
prefix = package_parts
|
||||
if node.module:
|
||||
prefix.extend(node.module.split("."))
|
||||
return ".".join(prefix)
|
||||
|
||||
|
||||
class _SourceVisitor(ast.NodeVisitor):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
module: str,
|
||||
module_paths: Mapping[str, str],
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.module = module
|
||||
self.module_paths = module_paths
|
||||
self.edges: set[tuple[str, str]] = set()
|
||||
self.dynamic_imports: list[DynamicImport] = []
|
||||
self.scope: list[str] = []
|
||||
self.builtins_aliases: set[str] = {"builtins"}
|
||||
self.builtin_import_aliases: set[str] = {"__import__"}
|
||||
self.importlib_aliases: set[str] = {"importlib"}
|
||||
self.import_module_aliases: set[str] = set()
|
||||
|
||||
def _add_edge(self, imported: str) -> None:
|
||||
# IMPORTANT: require an exact owned module. Falling back to the nearest
|
||||
# package turns missing optional submodules into false dependency edges.
|
||||
target = imported if imported in self.module_paths else None
|
||||
if target and target != self.module:
|
||||
self.edges.add((self.module, target))
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
if alias.name == "builtins":
|
||||
self.builtins_aliases.add(alias.asname or alias.name)
|
||||
if alias.name == "importlib":
|
||||
self.importlib_aliases.add(alias.asname or alias.name)
|
||||
self._add_edge(alias.name)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
base = _resolve_relative_import(self.module, self.path, node)
|
||||
if node.level == 0 and node.module == "importlib":
|
||||
for alias in node.names:
|
||||
if alias.name == "import_module":
|
||||
self.import_module_aliases.add(alias.asname or alias.name)
|
||||
if node.level == 0 and node.module == "builtins":
|
||||
for alias in node.names:
|
||||
if alias.name == "__import__":
|
||||
self.builtin_import_aliases.add(alias.asname or alias.name)
|
||||
exact_children: list[str] = []
|
||||
for alias in node.names:
|
||||
candidate = f"{base}.{alias.name}" if base else alias.name
|
||||
if candidate in self.module_paths:
|
||||
exact_children.append(candidate)
|
||||
if exact_children:
|
||||
for candidate in exact_children:
|
||||
self._add_edge(candidate)
|
||||
elif base:
|
||||
self._add_edge(base)
|
||||
|
||||
def _visit_scoped(
|
||||
self,
|
||||
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
||||
) -> None:
|
||||
self.scope.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.scope.pop()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
self._visit_scoped(node)
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
||||
self._visit_scoped(node)
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
self._visit_scoped(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
callee = ""
|
||||
if isinstance(node.func, ast.Name):
|
||||
if node.func.id in self.builtin_import_aliases:
|
||||
callee = "__import__"
|
||||
elif node.func.id in self.import_module_aliases:
|
||||
callee = "import_module"
|
||||
elif (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "__import__"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id in self.builtins_aliases
|
||||
):
|
||||
callee = "__import__"
|
||||
elif (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "import_module"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id in self.importlib_aliases
|
||||
):
|
||||
callee = "importlib.import_module"
|
||||
if callee:
|
||||
if (
|
||||
node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)
|
||||
):
|
||||
target_kind = "literal"
|
||||
target = node.args[0].value
|
||||
elif node.args:
|
||||
target_kind = "expression"
|
||||
argument = node.args[0]
|
||||
target = (
|
||||
argument.id
|
||||
if isinstance(argument, ast.Name)
|
||||
else f"<{type(argument).__name__}>"
|
||||
)
|
||||
else:
|
||||
target_kind = "expression"
|
||||
target = "<missing>"
|
||||
self.dynamic_imports.append(
|
||||
DynamicImport(
|
||||
path=self.path,
|
||||
scope=".".join(self.scope) or "<module>",
|
||||
callee=callee,
|
||||
target_kind=target_kind,
|
||||
target=target,
|
||||
line=node.lineno,
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def _strongly_connected_components(
|
||||
modules: Iterable[str],
|
||||
edges: Iterable[tuple[str, str]],
|
||||
) -> tuple[tuple[str, ...], ...]:
|
||||
adjacency: dict[str, set[str]] = defaultdict(set)
|
||||
for importer, imported in edges:
|
||||
adjacency[importer].add(imported)
|
||||
next_index = 0
|
||||
indices: dict[str, int] = {}
|
||||
low_links: dict[str, int] = {}
|
||||
stack: list[str] = []
|
||||
on_stack: set[str] = set()
|
||||
components: list[tuple[str, ...]] = []
|
||||
|
||||
def visit(module: str) -> None:
|
||||
nonlocal next_index
|
||||
indices[module] = next_index
|
||||
low_links[module] = next_index
|
||||
next_index += 1
|
||||
stack.append(module)
|
||||
on_stack.add(module)
|
||||
for imported in sorted(adjacency[module]):
|
||||
if imported not in indices:
|
||||
visit(imported)
|
||||
low_links[module] = min(low_links[module], low_links[imported])
|
||||
elif imported in on_stack:
|
||||
low_links[module] = min(low_links[module], indices[imported])
|
||||
if low_links[module] != indices[module]:
|
||||
return
|
||||
component: list[str] = []
|
||||
while True:
|
||||
member = stack.pop()
|
||||
on_stack.remove(member)
|
||||
component.append(member)
|
||||
if member == module:
|
||||
break
|
||||
if len(component) > 1:
|
||||
components.append(tuple(sorted(component)))
|
||||
|
||||
for module in sorted(modules):
|
||||
if module not in indices:
|
||||
visit(module)
|
||||
return tuple(sorted(components))
|
||||
|
||||
|
||||
def analyze_repository(
|
||||
repo_root: Path,
|
||||
policy: Mapping[str, Any],
|
||||
*,
|
||||
tracked_files: Iterable[str] | None = None,
|
||||
) -> Analysis:
|
||||
"""Analyze a repository without importing or executing its source modules."""
|
||||
|
||||
repo_root = repo_root.resolve()
|
||||
context, findings = _validate_policy(repo_root, policy, tracked_files)
|
||||
edges: set[tuple[str, str]] = set()
|
||||
dynamic_imports: list[DynamicImport] = []
|
||||
for path in sorted(context.owned_paths):
|
||||
source_path = repo_root / path
|
||||
if not source_path.is_file() or path not in context.path_modules:
|
||||
continue
|
||||
try:
|
||||
# IMPORTANT: tokenize.open handles encoding cookies and existing UTF-8 BOMs
|
||||
# without rewriting source or importing production modules.
|
||||
with tokenize.open(source_path) as source_file:
|
||||
tree = ast.parse(source_file.read(), filename=path)
|
||||
except (OSError, SyntaxError, UnicodeError) as exc:
|
||||
findings.append(
|
||||
_finding("SOURCE_PARSE", path=path, subject=type(exc).__name__)
|
||||
)
|
||||
continue
|
||||
visitor = _SourceVisitor(
|
||||
path=path,
|
||||
module=context.path_modules[path],
|
||||
module_paths=context.module_paths,
|
||||
)
|
||||
visitor.visit(tree)
|
||||
edges.update(visitor.edges)
|
||||
dynamic_imports.extend(visitor.dynamic_imports)
|
||||
|
||||
for importer, imported in sorted(edges):
|
||||
importer_path = context.module_paths.get(importer, ".")
|
||||
importer_domain = context.path_domains.get(importer_path)
|
||||
imported_path = context.module_paths.get(imported, ".")
|
||||
imported_domain = context.path_domains.get(imported_path)
|
||||
if not importer_domain or not imported_domain:
|
||||
continue
|
||||
allowed = imported_domain in context.allowed_dependencies.get(
|
||||
importer_domain, set()
|
||||
)
|
||||
exception = (importer, imported) in context.compatibility_exceptions
|
||||
if not allowed and not exception:
|
||||
findings.append(
|
||||
_finding(
|
||||
"DEP_FORBIDDEN_DIRECTION",
|
||||
path=importer_path,
|
||||
subject=f"{importer}->{imported}",
|
||||
)
|
||||
)
|
||||
for importer, imported in sorted(context.compatibility_exceptions):
|
||||
if (importer, imported) not in edges:
|
||||
path = context.module_paths.get(importer, ".")
|
||||
findings.append(
|
||||
_finding(
|
||||
"DEP_STALE_EXCEPTION",
|
||||
path=path,
|
||||
subject=f"{importer}->{imported}",
|
||||
)
|
||||
)
|
||||
|
||||
for facade, implementation in sorted(context.facade_contracts):
|
||||
if (facade, implementation) not in edges:
|
||||
path = context.module_paths.get(facade, ".")
|
||||
findings.append(
|
||||
_finding(
|
||||
"FACADE_STALE",
|
||||
path=path,
|
||||
subject=f"{facade}->{implementation}",
|
||||
)
|
||||
)
|
||||
if (implementation, facade) in edges:
|
||||
path = context.module_paths.get(implementation, ".")
|
||||
findings.append(
|
||||
_finding(
|
||||
"FACADE_REVERSE_DEPENDENCY",
|
||||
path=path,
|
||||
subject=f"{implementation}->{facade}",
|
||||
)
|
||||
)
|
||||
|
||||
cycles = _strongly_connected_components(context.module_paths, edges)
|
||||
current_cycle_sets = {frozenset(cycle) for cycle in cycles}
|
||||
for cycle in cycles:
|
||||
if frozenset(cycle) not in context.accepted_cycles:
|
||||
path = context.module_paths.get(cycle[0], ".")
|
||||
findings.append(_finding("CYCLE_NEW", path=path, subject="|".join(cycle)))
|
||||
for accepted_cycle in sorted(
|
||||
context.accepted_cycles, key=lambda item: sorted(item)
|
||||
):
|
||||
if accepted_cycle not in current_cycle_sets:
|
||||
first = sorted(accepted_cycle)[0] if accepted_cycle else ""
|
||||
path = context.module_paths.get(first, ".")
|
||||
findings.append(
|
||||
_finding(
|
||||
"CYCLE_STALE",
|
||||
path=path,
|
||||
subject="|".join(sorted(accepted_cycle)),
|
||||
)
|
||||
)
|
||||
|
||||
current_dynamic = {site.identity: site for site in dynamic_imports}
|
||||
for identity, site in sorted(current_dynamic.items()):
|
||||
if identity not in context.dynamic_imports:
|
||||
rule_id = (
|
||||
"DYNAMIC_UNREGISTERED_LITERAL"
|
||||
if site.target_kind == "literal"
|
||||
else "DYNAMIC_UNREGISTERED_EXPRESSION"
|
||||
)
|
||||
findings.append(
|
||||
_finding(
|
||||
rule_id,
|
||||
path=site.path,
|
||||
line=site.line,
|
||||
subject=f"{site.scope}:{site.callee}",
|
||||
)
|
||||
)
|
||||
for identity in sorted(context.dynamic_imports):
|
||||
if identity not in current_dynamic:
|
||||
path, scope, callee, _, _ = identity
|
||||
findings.append(
|
||||
_finding(
|
||||
"DYNAMIC_STALE",
|
||||
path=path,
|
||||
subject=f"{scope}:{callee}",
|
||||
)
|
||||
)
|
||||
|
||||
return Analysis(
|
||||
owned_paths=tuple(sorted(context.owned_paths)),
|
||||
static_edges=tuple(sorted(edges)),
|
||||
dynamic_imports=tuple(sorted(dynamic_imports)),
|
||||
cycles=cycles,
|
||||
findings=tuple(sorted(set(findings))),
|
||||
)
|
||||
|
||||
|
||||
def verify_repository(
|
||||
repo_root: Path,
|
||||
policy: Mapping[str, Any],
|
||||
*,
|
||||
tracked_files: Iterable[str] | None = None,
|
||||
) -> tuple[Finding, ...]:
|
||||
return analyze_repository(
|
||||
repo_root,
|
||||
policy,
|
||||
tracked_files=tracked_files,
|
||||
).findings
|
||||
|
||||
|
||||
def evaluate_repository(
|
||||
repo_root: Path,
|
||||
policy: Mapping[str, Any],
|
||||
*,
|
||||
tracked_files: Iterable[str] | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Compatibility facade returning the deterministic findings as a list."""
|
||||
|
||||
return list(verify_repository(repo_root, policy, tracked_files=tracked_files))
|
||||
|
||||
|
||||
def render_findings(findings: Sequence[Finding]) -> str:
|
||||
lines: list[str] = []
|
||||
for finding in sorted(findings):
|
||||
location = finding.path
|
||||
if finding.line:
|
||||
location = f"{location}:{finding.line}"
|
||||
# Security boundary: CLI output is limited to rule IDs and repository-relative
|
||||
# locations. Internal graph identities remain available to in-process tests.
|
||||
lines.append(f"{finding.rule_id} {location}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _load_policy(path: Path) -> Mapping[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("policy root must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def run_cli(
|
||||
repo_root: Path,
|
||||
policy_path: Path,
|
||||
*,
|
||||
max_findings: int = MAX_FINDINGS,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Run the bounded CLI contract without printing or leaking host paths."""
|
||||
|
||||
repo_root = repo_root.resolve()
|
||||
policy_path = policy_path if policy_path.is_absolute() else repo_root / policy_path
|
||||
try:
|
||||
policy_path.resolve().relative_to(repo_root)
|
||||
except ValueError:
|
||||
return 2, ["POLICY_PATH_OUTSIDE ."]
|
||||
try:
|
||||
policy = _load_policy(policy_path)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, ValueError):
|
||||
return 2, [f"POLICY_JSON_INVALID {POLICY_PATH}"]
|
||||
|
||||
findings = verify_repository(repo_root, policy)
|
||||
if not findings:
|
||||
return 0, ["DEPENDENCY_POLICY_PASS ."]
|
||||
|
||||
limit = max(1, min(int(max_findings), MAX_FINDINGS))
|
||||
visible = findings[:limit]
|
||||
lines = [finding.render() for finding in visible]
|
||||
omitted = len(findings) - len(visible)
|
||||
if omitted:
|
||||
lines.append(f"FINDINGS_TRUNCATED - {omitted} omitted")
|
||||
return 1, lines
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo-root", type=Path, default=Path(__file__).parents[1])
|
||||
parser.add_argument("--policy", type=Path)
|
||||
parser.add_argument("--max-findings", type=int, default=MAX_FINDINGS)
|
||||
args = parser.parse_args(argv)
|
||||
repo_root = args.repo_root.resolve()
|
||||
policy_path = args.policy or (repo_root / POLICY_PATH)
|
||||
exit_code, lines = run_cli(
|
||||
repo_root,
|
||||
policy_path,
|
||||
max_findings=args.max_findings,
|
||||
)
|
||||
for line in lines:
|
||||
print(line)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,628 @@
|
||||
"""Incremental Ruff/Mypy debt-ratchet verifier.
|
||||
|
||||
The policy owns source paths and normalized diagnostic counts. Line numbers are
|
||||
intentionally excluded so harmless edits do not churn the baseline; count changes
|
||||
still fail and require an explicit reviewed baseline refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Diagnostic:
|
||||
tool: str
|
||||
path: str
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class _ProcessResult(Protocol):
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class ToolExecutionError(RuntimeError):
|
||||
"""Raised with content-free context when a quality tool cannot run."""
|
||||
|
||||
|
||||
def _normalize_message(message: Any, repo_root: Path | None = None) -> str:
|
||||
normalized = " ".join(str(message or "").split())
|
||||
if repo_root is not None:
|
||||
variants = {
|
||||
str(repo_root.resolve()),
|
||||
str(repo_root.resolve()).replace("\\", "/"),
|
||||
}
|
||||
for variant in sorted(variants, key=len, reverse=True):
|
||||
if variant:
|
||||
normalized = normalized.replace(variant, "<repo>")
|
||||
return normalized
|
||||
|
||||
|
||||
def _is_safe_relative_path(value: Any) -> bool:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
candidate = Path(value)
|
||||
return not candidate.is_absolute() and ".." not in candidate.parts
|
||||
|
||||
|
||||
def _repo_relative_path(path_value: Any, repo_root: Path) -> str:
|
||||
path = Path(str(path_value))
|
||||
if not path.is_absolute():
|
||||
path = repo_root / path
|
||||
try:
|
||||
relative = path.resolve().relative_to(repo_root.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"diagnostic path is outside repository: {path_value}"
|
||||
) from exc
|
||||
return relative.as_posix()
|
||||
|
||||
|
||||
def _path_within(path: str, root: str) -> bool:
|
||||
path_obj = Path(path)
|
||||
root_obj = Path(root)
|
||||
return path_obj == root_obj or root_obj in path_obj.parents
|
||||
|
||||
|
||||
def _excluded_path_values(policy: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
entries = policy.get("excluded_paths", [])
|
||||
if not isinstance(entries, list):
|
||||
return ()
|
||||
return tuple(
|
||||
str(entry.get("path", ""))
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and entry.get("path")
|
||||
)
|
||||
|
||||
|
||||
def _tracked_python_files(repo_root: Path) -> frozenset[str] | None:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--", "*.py"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
# IMPORTANT: governance ownership must not include ignored maintainer-local files.
|
||||
return frozenset(
|
||||
line.strip().replace("\\", "/")
|
||||
for line in result.stdout.splitlines()
|
||||
if line.strip()
|
||||
)
|
||||
|
||||
|
||||
def discover_owned_python_files(
|
||||
repo_root: Path, policy: Mapping[str, Any]
|
||||
) -> tuple[str, ...]:
|
||||
excluded = _excluded_path_values(policy)
|
||||
tracked_files = _tracked_python_files(repo_root)
|
||||
discovered: set[str] = set()
|
||||
for root_value in policy.get("production_roots", []):
|
||||
root_path = repo_root / str(root_value)
|
||||
candidates: Iterable[Path]
|
||||
if root_path.is_file():
|
||||
candidates = (root_path,) if root_path.suffix == ".py" else ()
|
||||
elif root_path.is_dir():
|
||||
candidates = root_path.rglob("*.py")
|
||||
else:
|
||||
continue
|
||||
for candidate in candidates:
|
||||
relative = _repo_relative_path(candidate, repo_root)
|
||||
if tracked_files is not None and relative not in tracked_files:
|
||||
continue
|
||||
if any(_path_within(relative, excluded_path) for excluded_path in excluded):
|
||||
continue
|
||||
discovered.add(relative)
|
||||
return tuple(sorted(discovered))
|
||||
|
||||
|
||||
def _baseline_counter(policy: Mapping[str, Any]) -> Counter[Diagnostic]:
|
||||
baseline: Counter[Diagnostic] = Counter()
|
||||
entries = policy.get("baseline", [])
|
||||
if not isinstance(entries, list):
|
||||
return baseline
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
diagnostic = Diagnostic(
|
||||
tool=str(entry.get("tool", "")),
|
||||
path=str(entry.get("path", "")),
|
||||
code=str(entry.get("code", "")),
|
||||
message=_normalize_message(entry.get("message", "")),
|
||||
)
|
||||
count = entry.get("count", 0)
|
||||
if isinstance(count, int) and count > 0:
|
||||
baseline[diagnostic] += count
|
||||
return baseline
|
||||
|
||||
|
||||
def validate_policy(repo_root: Path, policy: Mapping[str, Any]) -> list[str]:
|
||||
failures: list[str] = []
|
||||
if policy.get("schema_version") != 1:
|
||||
failures.append("schema_version must be 1")
|
||||
|
||||
review = policy.get("review")
|
||||
if not isinstance(review, dict):
|
||||
failures.append("review must be an object")
|
||||
else:
|
||||
owner = review.get("owner")
|
||||
if not isinstance(owner, str) or not owner.strip():
|
||||
failures.append("review.owner must be a non-empty string")
|
||||
parsed_dates: dict[str, date] = {}
|
||||
for key in ("reviewed_at", "next_review_by"):
|
||||
try:
|
||||
parsed_dates[key] = date.fromisoformat(str(review.get(key, "")))
|
||||
except ValueError:
|
||||
failures.append(f"review.{key} must be an ISO date")
|
||||
if (
|
||||
len(parsed_dates) == 2
|
||||
and parsed_dates["next_review_by"] < parsed_dates["reviewed_at"]
|
||||
):
|
||||
failures.append("review.next_review_by must not precede reviewed_at")
|
||||
|
||||
tools = policy.get("tools")
|
||||
if not isinstance(tools, dict) or set(tools) != {"ruff", "mypy"}:
|
||||
failures.append("tools must contain exactly ruff and mypy")
|
||||
tools = {}
|
||||
for tool_name in ("ruff", "mypy"):
|
||||
config = tools.get(tool_name)
|
||||
if (
|
||||
not isinstance(config, dict)
|
||||
or not isinstance(config.get("version"), str)
|
||||
or not config["version"].strip()
|
||||
):
|
||||
failures.append(f"tools.{tool_name}.version must be a non-empty string")
|
||||
|
||||
roots = policy.get("production_roots")
|
||||
if not isinstance(roots, list) or not roots:
|
||||
failures.append("production_roots must be a non-empty list")
|
||||
roots = []
|
||||
seen_roots: set[str] = set()
|
||||
valid_roots: list[str] = []
|
||||
for index, value in enumerate(roots):
|
||||
if not _is_safe_relative_path(value):
|
||||
failures.append(f"production_roots[{index}] is unsafe: {value!r}")
|
||||
continue
|
||||
value = str(value)
|
||||
if value in seen_roots:
|
||||
failures.append(f"duplicate production root: {value}")
|
||||
continue
|
||||
seen_roots.add(value)
|
||||
valid_roots.append(value)
|
||||
if not (repo_root / value).exists():
|
||||
failures.append(f"production root is missing: {value}")
|
||||
|
||||
excluded_entries = policy.get("excluded_paths")
|
||||
if not isinstance(excluded_entries, list):
|
||||
failures.append("excluded_paths must be a list")
|
||||
excluded_entries = []
|
||||
seen_excluded: set[str] = set()
|
||||
for index, entry in enumerate(excluded_entries):
|
||||
if not isinstance(entry, dict):
|
||||
failures.append(f"excluded_paths[{index}] must be an object")
|
||||
continue
|
||||
value = entry.get("path")
|
||||
reason = entry.get("reason")
|
||||
if not _is_safe_relative_path(value):
|
||||
failures.append(f"excluded_paths[{index}] is unsafe: {value!r}")
|
||||
continue
|
||||
value = str(value)
|
||||
if value in seen_excluded:
|
||||
failures.append(f"duplicate excluded path: {value}")
|
||||
seen_excluded.add(value)
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
failures.append(f"excluded path {value} is missing a reason")
|
||||
if not any(_path_within(value, root) for root in valid_roots):
|
||||
failures.append(f"excluded path is outside owned roots: {value}")
|
||||
if not (repo_root / value).exists():
|
||||
failures.append(f"excluded path is missing: {value}")
|
||||
|
||||
owned_files = set(discover_owned_python_files(repo_root, policy))
|
||||
strict_paths = policy.get("strict_paths")
|
||||
if not isinstance(strict_paths, list):
|
||||
failures.append("strict_paths must be a list")
|
||||
strict_paths = []
|
||||
seen_strict: set[str] = set()
|
||||
for index, value in enumerate(strict_paths):
|
||||
if not _is_safe_relative_path(value):
|
||||
failures.append(f"strict_paths[{index}] is unsafe: {value!r}")
|
||||
continue
|
||||
value = str(value)
|
||||
if value in seen_strict:
|
||||
failures.append(f"duplicate strict path: {value}")
|
||||
seen_strict.add(value)
|
||||
if not any(_path_within(path, value) for path in owned_files):
|
||||
failures.append(f"strict path has no owned Python files: {value}")
|
||||
|
||||
baseline_entries = policy.get("baseline")
|
||||
if not isinstance(baseline_entries, list):
|
||||
failures.append("baseline must be a list")
|
||||
baseline_entries = []
|
||||
seen_diagnostics: set[Diagnostic] = set()
|
||||
for index, entry in enumerate(baseline_entries):
|
||||
if not isinstance(entry, dict):
|
||||
failures.append(f"baseline[{index}] must be an object")
|
||||
continue
|
||||
diagnostic = Diagnostic(
|
||||
tool=str(entry.get("tool", "")),
|
||||
path=str(entry.get("path", "")),
|
||||
code=str(entry.get("code", "")),
|
||||
message=_normalize_message(entry.get("message", "")),
|
||||
)
|
||||
if diagnostic.tool not in {"ruff", "mypy"}:
|
||||
failures.append(f"baseline[{index}] has unknown tool {diagnostic.tool!r}")
|
||||
if diagnostic.path not in owned_files:
|
||||
failures.append(
|
||||
f"baseline[{index}] path is not an owned Python file: {diagnostic.path}"
|
||||
)
|
||||
if not diagnostic.code or not diagnostic.message:
|
||||
failures.append(f"baseline[{index}] code/message must be non-empty")
|
||||
count = entry.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
||||
failures.append(f"baseline[{index}] count must be a positive integer")
|
||||
if diagnostic in seen_diagnostics:
|
||||
failures.append(f"duplicate baseline diagnostic: {diagnostic}")
|
||||
seen_diagnostics.add(diagnostic)
|
||||
|
||||
if baseline_entries != serialize_baseline(_baseline_counter(policy)):
|
||||
failures.append("baseline must use canonical sorted serialization")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def validate_tool_versions(
|
||||
policy: Mapping[str, Any], detected_versions: Mapping[str, str]
|
||||
) -> list[str]:
|
||||
failures: list[str] = []
|
||||
tools = policy.get("tools", {})
|
||||
for tool_name in ("ruff", "mypy"):
|
||||
config = tools.get(tool_name, {}) if isinstance(tools, dict) else {}
|
||||
expected = config.get("version") if isinstance(config, dict) else None
|
||||
found = detected_versions.get(tool_name, "missing")
|
||||
if expected != found:
|
||||
failures.append(
|
||||
f"{tool_name} version drift: expected {expected}, found {found}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def validate_requirement_pins(
|
||||
policy: Mapping[str, Any], requirement_lines: Iterable[str]
|
||||
) -> list[str]:
|
||||
requirements: dict[str, str] = {}
|
||||
for raw_line in requirement_lines:
|
||||
line = raw_line.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
match = re.match(r"^(ruff|mypy)(?:\[.*\])?(.*)$", line, re.IGNORECASE)
|
||||
if match:
|
||||
requirements[match.group(1).lower()] = (
|
||||
match.group(1).lower() + match.group(2).strip()
|
||||
)
|
||||
|
||||
failures: list[str] = []
|
||||
tools = policy.get("tools", {})
|
||||
for tool_name in sorted(("ruff", "mypy")):
|
||||
config = tools.get(tool_name, {}) if isinstance(tools, dict) else {}
|
||||
version = config.get("version") if isinstance(config, dict) else None
|
||||
expected = f"{tool_name}=={version}"
|
||||
found = requirements.get(tool_name, "missing")
|
||||
if found != expected:
|
||||
failures.append(
|
||||
f"{tool_name} requirement drift: expected {expected}, found {found}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def _run_command(
|
||||
runner: Callable[..., _ProcessResult], command: list[str], repo_root: Path
|
||||
) -> _ProcessResult:
|
||||
return runner(
|
||||
command,
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
|
||||
|
||||
def _parse_tool_version(tool_name: str, output: str) -> str:
|
||||
match = re.search(rf"\b{re.escape(tool_name)}\s+([0-9]+(?:\.[0-9]+)+)", output)
|
||||
if not match:
|
||||
raise ToolExecutionError(f"{tool_name} version output was not recognized")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def run_static_analysis(
|
||||
repo_root: Path,
|
||||
policy: Mapping[str, Any],
|
||||
*,
|
||||
runner: Callable[..., _ProcessResult] = subprocess.run,
|
||||
) -> tuple[dict[str, str], Counter[Diagnostic]]:
|
||||
owned_files = list(discover_owned_python_files(repo_root, policy))
|
||||
versions: dict[str, str] = {}
|
||||
|
||||
for tool_name in ("ruff", "mypy"):
|
||||
command = [sys.executable, "-m", tool_name, "--version"]
|
||||
result = _run_command(runner, command, repo_root)
|
||||
if result.returncode != 0:
|
||||
raise ToolExecutionError(
|
||||
f"{tool_name} version check failed with exit code {result.returncode}"
|
||||
)
|
||||
versions[tool_name] = _parse_tool_version(tool_name, result.stdout)
|
||||
|
||||
commands = (
|
||||
(
|
||||
"ruff",
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ruff",
|
||||
"check",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--no-cache",
|
||||
*owned_files,
|
||||
],
|
||||
),
|
||||
(
|
||||
"mypy",
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mypy",
|
||||
"--output",
|
||||
"json",
|
||||
"--no-incremental",
|
||||
"--explicit-package-bases",
|
||||
"--no-warn-unused-configs",
|
||||
"--no-error-summary",
|
||||
"--no-site-packages",
|
||||
"--ignore-missing-imports",
|
||||
*owned_files,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
diagnostics: Counter[Diagnostic] = Counter()
|
||||
for tool_name, command in commands:
|
||||
result = _run_command(runner, command, repo_root)
|
||||
if result.returncode not in {0, 1}:
|
||||
# SECURITY: stderr may contain private host paths or source content.
|
||||
# Keep the public/loggable failure content-free and deterministic.
|
||||
raise ToolExecutionError(
|
||||
f"{tool_name} execution failed with exit code {result.returncode}"
|
||||
)
|
||||
if tool_name == "ruff":
|
||||
diagnostics.update(parse_ruff_output(result.stdout, repo_root))
|
||||
else:
|
||||
diagnostics.update(parse_mypy_output(result.stdout, repo_root))
|
||||
return versions, diagnostics
|
||||
|
||||
|
||||
def compare_diagnostics(
|
||||
policy: Mapping[str, Any], current: Counter[Diagnostic]
|
||||
) -> list[str]:
|
||||
failures: list[str] = []
|
||||
baseline = _baseline_counter(policy)
|
||||
strict_paths = tuple(str(value) for value in policy.get("strict_paths", []))
|
||||
|
||||
for diagnostic, count in sorted(current.items()):
|
||||
if any(_path_within(diagnostic.path, path) for path in strict_paths):
|
||||
failures.append(
|
||||
"strict path diagnostic: "
|
||||
f"{diagnostic.tool}:{diagnostic.path}:{diagnostic.code} x{count}"
|
||||
)
|
||||
|
||||
for diagnostic in sorted(set(baseline) | set(current)):
|
||||
expected = baseline.get(diagnostic, 0)
|
||||
found = current.get(diagnostic, 0)
|
||||
label = (
|
||||
f"{diagnostic.tool}:{diagnostic.path}:{diagnostic.code}:"
|
||||
f"{diagnostic.message}"
|
||||
)
|
||||
if found > expected:
|
||||
failures.append(f"new debt: {label} expected {expected}, found {found}")
|
||||
elif found < expected:
|
||||
failures.append(
|
||||
f"stale baseline: {label} expected {expected}, found {found}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def _diagnostic_from_payload(
|
||||
*, tool: str, payload: Mapping[str, Any], path_key: str, repo_root: Path
|
||||
) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
tool=tool,
|
||||
path=_repo_relative_path(payload.get(path_key, ""), repo_root),
|
||||
code=str(payload.get("code") or "unknown"),
|
||||
message=_normalize_message(payload.get("message", ""), repo_root),
|
||||
)
|
||||
|
||||
|
||||
def parse_ruff_output(raw: str, repo_root: Path) -> Counter[Diagnostic]:
|
||||
payload = json.loads(raw or "[]")
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Ruff JSON output must be a list")
|
||||
diagnostics: Counter[Diagnostic] = Counter()
|
||||
for entry in payload:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("Ruff JSON diagnostic must be an object")
|
||||
diagnostics[
|
||||
_diagnostic_from_payload(
|
||||
tool="ruff", payload=entry, path_key="filename", repo_root=repo_root
|
||||
)
|
||||
] += 1
|
||||
return diagnostics
|
||||
|
||||
|
||||
def parse_mypy_output(raw: str, repo_root: Path) -> Counter[Diagnostic]:
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return Counter()
|
||||
if stripped.startswith("["):
|
||||
payloads = json.loads(stripped)
|
||||
else:
|
||||
payloads = [json.loads(line) for line in stripped.splitlines() if line.strip()]
|
||||
if not isinstance(payloads, list):
|
||||
raise ValueError("Mypy JSON output must be a list or JSON lines")
|
||||
diagnostics: Counter[Diagnostic] = Counter()
|
||||
for entry in payloads:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("Mypy JSON diagnostic must be an object")
|
||||
if entry.get("severity", "error") != "error":
|
||||
continue
|
||||
diagnostics[
|
||||
_diagnostic_from_payload(
|
||||
tool="mypy", payload=entry, path_key="file", repo_root=repo_root
|
||||
)
|
||||
] += 1
|
||||
return diagnostics
|
||||
|
||||
|
||||
def serialize_baseline(diagnostics: Counter[Diagnostic]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"tool": diagnostic.tool,
|
||||
"path": diagnostic.path,
|
||||
"code": diagnostic.code,
|
||||
"message": diagnostic.message,
|
||||
"count": count,
|
||||
}
|
||||
for diagnostic, count in sorted(diagnostics.items())
|
||||
if count > 0
|
||||
]
|
||||
|
||||
|
||||
def with_updated_baseline(
|
||||
policy: Mapping[str, Any], diagnostics: Counter[Diagnostic]
|
||||
) -> dict[str, Any]:
|
||||
updated = deepcopy(dict(policy))
|
||||
updated["baseline"] = serialize_baseline(diagnostics)
|
||||
return updated
|
||||
|
||||
|
||||
def evaluate_policy(
|
||||
repo_root: Path,
|
||||
policy: Mapping[str, Any],
|
||||
*,
|
||||
requirement_lines: Iterable[str],
|
||||
runner: Callable[..., _ProcessResult] = subprocess.run,
|
||||
) -> tuple[list[str], Counter[Diagnostic]]:
|
||||
failures = validate_policy(repo_root, policy)
|
||||
if failures:
|
||||
return failures, Counter()
|
||||
failures.extend(validate_requirement_pins(policy, requirement_lines))
|
||||
|
||||
versions, diagnostics = run_static_analysis(repo_root, policy, runner=runner)
|
||||
failures.extend(validate_tool_versions(policy, versions))
|
||||
failures.extend(compare_diagnostics(policy, diagnostics))
|
||||
return failures, diagnostics
|
||||
|
||||
|
||||
def _write_policy(path: Path, policy: Mapping[str, Any]) -> None:
|
||||
path.write_text(
|
||||
json.dumps(policy, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify the incremental Ruff/Mypy static-analysis debt policy."
|
||||
)
|
||||
parser.add_argument("--repo-root", default=".")
|
||||
parser.add_argument("--policy", default="tests/static_analysis_policy.json")
|
||||
parser.add_argument("--requirements", default="requirements-quality.txt")
|
||||
parser.add_argument(
|
||||
"--write-baseline",
|
||||
action="store_true",
|
||||
help="Explicitly replace the accepted diagnostic baseline after review.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
policy_path = repo_root / args.policy
|
||||
requirements_path = repo_root / args.requirements
|
||||
try:
|
||||
policy = json.loads(policy_path.read_text(encoding="utf-8"))
|
||||
requirement_lines = requirements_path.read_text(encoding="utf-8").splitlines()
|
||||
structural_failures = validate_policy(repo_root, policy)
|
||||
structural_failures.extend(validate_requirement_pins(policy, requirement_lines))
|
||||
if structural_failures:
|
||||
for failure in structural_failures:
|
||||
print(f"STATIC-ANALYSIS-FAIL: {failure}")
|
||||
return 1
|
||||
|
||||
versions, diagnostics = run_static_analysis(repo_root, policy)
|
||||
failures = validate_tool_versions(policy, versions)
|
||||
if args.write_baseline:
|
||||
strict_policy = with_updated_baseline(policy, diagnostics)
|
||||
strict_failures = [
|
||||
failure
|
||||
for failure in compare_diagnostics(strict_policy, diagnostics)
|
||||
if failure.startswith("strict path diagnostic:")
|
||||
]
|
||||
if strict_failures:
|
||||
for failure in strict_failures:
|
||||
print(f"STATIC-ANALYSIS-FAIL: {failure}")
|
||||
return 1
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(f"STATIC-ANALYSIS-FAIL: {failure}")
|
||||
return 1
|
||||
_write_policy(policy_path, strict_policy)
|
||||
print(
|
||||
"STATIC-ANALYSIS-BASELINE-WRITTEN: "
|
||||
f"{len(diagnostics)} fingerprints, {sum(diagnostics.values())} findings"
|
||||
)
|
||||
return 0
|
||||
|
||||
failures.extend(compare_diagnostics(policy, diagnostics))
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(f"STATIC-ANALYSIS-FAIL: {failure}")
|
||||
return 1
|
||||
print(
|
||||
"STATIC-ANALYSIS-PASS: "
|
||||
f"{len(diagnostics)} fingerprints, {sum(diagnostics.values())} governed findings"
|
||||
)
|
||||
return 0
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(
|
||||
f"STATIC-ANALYSIS-FAIL: invalid policy or tool output ({type(exc).__name__})"
|
||||
)
|
||||
return 1
|
||||
except ToolExecutionError as exc:
|
||||
print(f"STATIC-ANALYSIS-FAIL: {exc}")
|
||||
print(
|
||||
"STATIC-ANALYSIS-REMEDIATION: use the project-local Python to install "
|
||||
"requirements-quality.txt"
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
# ruff: noqa: N999
|
||||
"""Bootstrap lifecycle and registration implementation package."""
|
||||
|
||||
__all__ = ["lifecycle", "registration"]
|
||||
@@ -0,0 +1,698 @@
|
||||
"""Typed startup lifecycle outcomes and redacted public diagnostics.
|
||||
|
||||
Required startup work still fails closed in callers. This module owns only the
|
||||
phase/result state machine and optional post-ready warmup observations; it does
|
||||
not own ComfyUI's application lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.services.startup_lifecycle")
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
MAX_DIAGNOSTIC_MS = 86_400_000
|
||||
MAX_WARMUPS = 16
|
||||
STARTUP_DIAGNOSTIC_KEYS = (
|
||||
"schema_version",
|
||||
"phase",
|
||||
"state",
|
||||
"reason_code",
|
||||
"ready",
|
||||
"degraded",
|
||||
"fatal",
|
||||
"attempt",
|
||||
"max_attempts",
|
||||
"elapsed_ms",
|
||||
"phase_elapsed_ms",
|
||||
"ready_elapsed_ms",
|
||||
"warmups",
|
||||
)
|
||||
|
||||
|
||||
class StartupPhase(str, Enum):
|
||||
PACKAGE_IMPORT = "package_import"
|
||||
REQUIRED_INITIALIZATION = "required_initialization"
|
||||
HOST_WAIT = "host_wait"
|
||||
ROUTE_REGISTRATION = "route_registration"
|
||||
COMPLETE = "complete"
|
||||
OPTIONAL_WARMUP = "optional_warmup"
|
||||
|
||||
|
||||
class StartupState(str, Enum):
|
||||
STARTING = "starting"
|
||||
INITIALIZING = "initializing"
|
||||
WAITING_FOR_HOST = "waiting_for_host"
|
||||
REGISTERING_ROUTES = "registering_routes"
|
||||
READY = "ready"
|
||||
DEGRADED = "degraded"
|
||||
FATAL = "fatal"
|
||||
|
||||
|
||||
class StartupReason(str, Enum):
|
||||
BOOTSTRAP_STARTED = "bootstrap_started"
|
||||
BOOTSTRAP_IMPORT_FAILED = "bootstrap_import_failed"
|
||||
REQUIRED_INITIALIZATION_STARTED = "required_initialization_started"
|
||||
REQUIRED_INITIALIZATION_FAILED = "required_initialization_failed"
|
||||
HOST_NOT_READY = "host_not_ready"
|
||||
ROUTE_REGISTRATION_STARTED = "route_registration_started"
|
||||
ROUTE_REGISTRATION_SUCCEEDED = "route_registration_succeeded"
|
||||
ROUTE_REGISTRATION_FAILED = "route_registration_failed"
|
||||
RETRY_EXHAUSTED = "retry_exhausted"
|
||||
WARMUP_STARTED = "warmup_started"
|
||||
WARMUP_SUCCEEDED = "warmup_succeeded"
|
||||
WARMUP_FAILED = "warmup_failed"
|
||||
WARMUP_TIMED_OUT = "warmup_timed_out"
|
||||
|
||||
|
||||
class WarmupState(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
TIMED_OUT = "timed_out"
|
||||
|
||||
|
||||
# Compatibility constants retained for existing internal imports.
|
||||
STARTUP_STARTING = StartupState.STARTING.value
|
||||
STARTUP_READY = StartupState.READY.value
|
||||
STARTUP_DEGRADED_WARMUP = StartupState.DEGRADED.value
|
||||
STARTUP_FATAL = StartupState.FATAL.value
|
||||
WARMUP_PENDING = WarmupState.PENDING.value
|
||||
WARMUP_RUNNING = WarmupState.RUNNING.value
|
||||
WARMUP_SUCCEEDED = WarmupState.SUCCEEDED.value
|
||||
WARMUP_FAILED = WarmupState.FAILED.value
|
||||
WARMUP_TIMED_OUT = WarmupState.TIMED_OUT.value
|
||||
|
||||
WarmupSpec = tuple[str, Callable[[], Any], float]
|
||||
|
||||
|
||||
class StartupTransitionError(RuntimeError):
|
||||
"""Stable transition failure that never embeds caller/source content."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WarmupOutcome:
|
||||
name: str
|
||||
state: WarmupState
|
||||
reason_code: StartupReason
|
||||
timeout_ms: int
|
||||
duration_ms: int
|
||||
|
||||
def to_diagnostics(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"state": self.state.value,
|
||||
"reason_code": self.reason_code.value,
|
||||
"timeout_ms": self.timeout_ms,
|
||||
"duration_ms": self.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartupOutcome:
|
||||
schema_version: int
|
||||
phase: StartupPhase
|
||||
state: StartupState
|
||||
reason_code: StartupReason
|
||||
ready: bool
|
||||
degraded: bool
|
||||
fatal: bool
|
||||
attempt: int
|
||||
max_attempts: int
|
||||
elapsed_ms: int
|
||||
phase_elapsed_ms: int
|
||||
ready_elapsed_ms: int | None
|
||||
warmups: tuple[WarmupOutcome, ...]
|
||||
|
||||
def to_diagnostics(self) -> dict[str, Any]:
|
||||
"""Return a fresh, ordered, JSON-safe public projection."""
|
||||
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"phase": self.phase.value,
|
||||
"state": self.state.value,
|
||||
"reason_code": self.reason_code.value,
|
||||
"ready": self.ready,
|
||||
"degraded": self.degraded,
|
||||
"fatal": self.fatal,
|
||||
"attempt": self.attempt,
|
||||
"max_attempts": self.max_attempts,
|
||||
"elapsed_ms": self.elapsed_ms,
|
||||
"phase_elapsed_ms": self.phase_elapsed_ms,
|
||||
"ready_elapsed_ms": self.ready_elapsed_ms,
|
||||
"warmups": [warmup.to_diagnostics() for warmup in self.warmups],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WarmupRecord:
|
||||
name: str
|
||||
state: WarmupState
|
||||
reason_code: StartupReason
|
||||
timeout_sec: float
|
||||
started_at: float | None = None
|
||||
completed_at: float | None = None
|
||||
|
||||
|
||||
def _bounded_ms(seconds: float) -> int:
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
return 0
|
||||
return min(round(seconds * 1000.0), MAX_DIAGNOSTIC_MS)
|
||||
|
||||
|
||||
_WARMUP_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
|
||||
|
||||
|
||||
def _safe_warmup_name(value: Any) -> str:
|
||||
normalized = _WARMUP_NAME_RE.sub("_", str(value or "warmup")).strip("._-")
|
||||
return (normalized or "warmup")[:64]
|
||||
|
||||
|
||||
class StartupLifecycle:
|
||||
"""Single lock-protected owner of startup phase and warmup outcomes."""
|
||||
|
||||
def __init__(self, *, monotonic_fn: Callable[[], float] = time.monotonic):
|
||||
self._clock = monotonic_fn
|
||||
self._lock = threading.RLock()
|
||||
self._generation = 0
|
||||
self._reset_locked()
|
||||
|
||||
def _reset_locked(self) -> None:
|
||||
now = self._clock()
|
||||
self._started_at = now
|
||||
self._phase_started_at = now
|
||||
self._ready_at: float | None = None
|
||||
self._phase = StartupPhase.PACKAGE_IMPORT
|
||||
self._state = StartupState.STARTING
|
||||
self._reason_code = StartupReason.BOOTSTRAP_STARTED
|
||||
self._attempt = 0
|
||||
self._max_attempts = 0
|
||||
self._warmups: dict[str, _WarmupRecord] = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._generation += 1
|
||||
self._reset_locked()
|
||||
|
||||
def _require_nonterminal(self) -> None:
|
||||
if self._state is StartupState.FATAL:
|
||||
raise StartupTransitionError("TERMINAL_STATE")
|
||||
|
||||
def _set(
|
||||
self,
|
||||
*,
|
||||
phase: StartupPhase,
|
||||
state: StartupState,
|
||||
reason_code: StartupReason,
|
||||
attempt: int | None = None,
|
||||
max_attempts: int | None = None,
|
||||
) -> None:
|
||||
now = self._clock()
|
||||
if phase is not self._phase:
|
||||
self._phase_started_at = now
|
||||
self._phase = phase
|
||||
self._state = state
|
||||
self._reason_code = reason_code
|
||||
if attempt is not None:
|
||||
self._attempt = attempt
|
||||
if max_attempts is not None:
|
||||
self._max_attempts = max_attempts
|
||||
if state is StartupState.READY and self._ready_at is None:
|
||||
self._ready_at = now
|
||||
|
||||
def mark_required_initialization_started(self) -> None:
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
if self._state is not StartupState.STARTING:
|
||||
raise StartupTransitionError("INVALID_TRANSITION")
|
||||
self._set(
|
||||
phase=StartupPhase.REQUIRED_INITIALIZATION,
|
||||
state=StartupState.INITIALIZING,
|
||||
reason_code=StartupReason.REQUIRED_INITIALIZATION_STARTED,
|
||||
)
|
||||
|
||||
def mark_host_waiting(self, *, attempt: int, max_attempts: int) -> None:
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
if max_attempts <= 0 or attempt < 0 or attempt > max_attempts:
|
||||
raise StartupTransitionError("ATTEMPT_OUT_OF_RANGE")
|
||||
if self._state is StartupState.INITIALIZING:
|
||||
if attempt != 0:
|
||||
raise StartupTransitionError("ATTEMPT_OUT_OF_RANGE")
|
||||
elif self._state is StartupState.WAITING_FOR_HOST:
|
||||
if attempt <= self._attempt:
|
||||
raise StartupTransitionError("ATTEMPT_NOT_INCREASING")
|
||||
if max_attempts != self._max_attempts:
|
||||
raise StartupTransitionError("ATTEMPT_BOUND_CHANGED")
|
||||
else:
|
||||
raise StartupTransitionError("INVALID_TRANSITION")
|
||||
self._set(
|
||||
phase=StartupPhase.HOST_WAIT,
|
||||
state=StartupState.WAITING_FOR_HOST,
|
||||
reason_code=StartupReason.HOST_NOT_READY,
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
|
||||
def mark_route_registration_started(
|
||||
self, *, attempt: int = 0, max_attempts: int = 0
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
if self._state not in {
|
||||
StartupState.INITIALIZING,
|
||||
StartupState.WAITING_FOR_HOST,
|
||||
}:
|
||||
raise StartupTransitionError("INVALID_TRANSITION")
|
||||
if attempt < 0 or max_attempts < 0 or attempt > max_attempts:
|
||||
raise StartupTransitionError("ATTEMPT_OUT_OF_RANGE")
|
||||
if self._state is StartupState.INITIALIZING:
|
||||
if attempt != 0 or max_attempts != 0:
|
||||
raise StartupTransitionError("ATTEMPT_OUT_OF_RANGE")
|
||||
else:
|
||||
if max_attempts != self._max_attempts:
|
||||
raise StartupTransitionError("ATTEMPT_BOUND_CHANGED")
|
||||
if attempt <= self._attempt:
|
||||
raise StartupTransitionError("ATTEMPT_NOT_INCREASING")
|
||||
self._set(
|
||||
phase=StartupPhase.ROUTE_REGISTRATION,
|
||||
state=StartupState.REGISTERING_ROUTES,
|
||||
reason_code=StartupReason.ROUTE_REGISTRATION_STARTED,
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
|
||||
def mark_ready(self) -> None:
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
if self._state is not StartupState.REGISTERING_ROUTES:
|
||||
raise StartupTransitionError("INVALID_TRANSITION")
|
||||
self._set(
|
||||
phase=StartupPhase.COMPLETE,
|
||||
state=StartupState.READY,
|
||||
reason_code=StartupReason.ROUTE_REGISTRATION_SUCCEEDED,
|
||||
)
|
||||
|
||||
def mark_fatal(
|
||||
self,
|
||||
*,
|
||||
phase: StartupPhase,
|
||||
reason_code: StartupReason,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
allowed = {
|
||||
StartupReason.BOOTSTRAP_IMPORT_FAILED,
|
||||
StartupReason.REQUIRED_INITIALIZATION_FAILED,
|
||||
StartupReason.ROUTE_REGISTRATION_FAILED,
|
||||
StartupReason.RETRY_EXHAUSTED,
|
||||
}
|
||||
if reason_code not in allowed:
|
||||
raise StartupTransitionError("INVALID_FATAL_REASON")
|
||||
expected_phase = {
|
||||
StartupReason.BOOTSTRAP_IMPORT_FAILED: StartupPhase.PACKAGE_IMPORT,
|
||||
StartupReason.REQUIRED_INITIALIZATION_FAILED: (
|
||||
StartupPhase.REQUIRED_INITIALIZATION
|
||||
),
|
||||
StartupReason.ROUTE_REGISTRATION_FAILED: (
|
||||
StartupPhase.ROUTE_REGISTRATION
|
||||
),
|
||||
StartupReason.RETRY_EXHAUSTED: StartupPhase.HOST_WAIT,
|
||||
}[reason_code]
|
||||
if phase is not expected_phase:
|
||||
raise StartupTransitionError("FATAL_PHASE_MISMATCH")
|
||||
allowed_states = {
|
||||
StartupReason.BOOTSTRAP_IMPORT_FAILED: {
|
||||
StartupState.STARTING,
|
||||
},
|
||||
StartupReason.REQUIRED_INITIALIZATION_FAILED: {
|
||||
StartupState.INITIALIZING,
|
||||
},
|
||||
StartupReason.ROUTE_REGISTRATION_FAILED: {
|
||||
StartupState.INITIALIZING,
|
||||
StartupState.WAITING_FOR_HOST,
|
||||
StartupState.REGISTERING_ROUTES,
|
||||
},
|
||||
StartupReason.RETRY_EXHAUSTED: {
|
||||
StartupState.WAITING_FOR_HOST,
|
||||
},
|
||||
}[reason_code]
|
||||
if self._state not in allowed_states:
|
||||
raise StartupTransitionError("INVALID_FATAL_TRANSITION")
|
||||
self._set(
|
||||
phase=phase,
|
||||
state=StartupState.FATAL,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
|
||||
def mark_retry_exhausted(self) -> None:
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
if self._state is not StartupState.WAITING_FOR_HOST:
|
||||
raise StartupTransitionError("INVALID_TRANSITION")
|
||||
self._set(
|
||||
phase=StartupPhase.HOST_WAIT,
|
||||
state=StartupState.FATAL,
|
||||
reason_code=StartupReason.RETRY_EXHAUSTED,
|
||||
)
|
||||
|
||||
def begin_warmup(self, name: str, timeout_sec: float) -> tuple[bool, int, str]:
|
||||
safe_name = _safe_warmup_name(name)
|
||||
timeout_sec = max(0.01, min(float(timeout_sec or 5.0), 60.0))
|
||||
with self._lock:
|
||||
self._require_nonterminal()
|
||||
if self._state not in {StartupState.READY, StartupState.DEGRADED}:
|
||||
raise StartupTransitionError("WARMUP_BEFORE_READY")
|
||||
existing = self._warmups.get(safe_name)
|
||||
if existing is not None:
|
||||
return False, self._generation, safe_name
|
||||
if len(self._warmups) >= MAX_WARMUPS:
|
||||
raise StartupTransitionError("WARMUP_LIMIT_EXCEEDED")
|
||||
self._warmups[safe_name] = _WarmupRecord(
|
||||
name=safe_name,
|
||||
state=WarmupState.PENDING,
|
||||
reason_code=StartupReason.WARMUP_STARTED,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
return True, self._generation, safe_name
|
||||
|
||||
def mark_warmup_running(self, name: str, generation: int) -> None:
|
||||
with self._lock:
|
||||
if generation != self._generation:
|
||||
return
|
||||
record = self._warmups.get(name)
|
||||
if record is None:
|
||||
return
|
||||
record.state = WarmupState.RUNNING
|
||||
record.reason_code = StartupReason.WARMUP_STARTED
|
||||
record.started_at = self._clock()
|
||||
if self._state is StartupState.READY:
|
||||
self._set(
|
||||
phase=StartupPhase.OPTIONAL_WARMUP,
|
||||
state=StartupState.READY,
|
||||
reason_code=StartupReason.WARMUP_STARTED,
|
||||
)
|
||||
|
||||
def finish_warmup(
|
||||
self,
|
||||
name: str,
|
||||
generation: int,
|
||||
*,
|
||||
state: WarmupState,
|
||||
) -> None:
|
||||
reason_by_state = {
|
||||
WarmupState.SUCCEEDED: StartupReason.WARMUP_SUCCEEDED,
|
||||
WarmupState.FAILED: StartupReason.WARMUP_FAILED,
|
||||
WarmupState.TIMED_OUT: StartupReason.WARMUP_TIMED_OUT,
|
||||
}
|
||||
reason = reason_by_state.get(state)
|
||||
if reason is None:
|
||||
raise StartupTransitionError("INVALID_WARMUP_RESULT")
|
||||
with self._lock:
|
||||
if generation != self._generation:
|
||||
return
|
||||
record = self._warmups.get(name)
|
||||
if record is None:
|
||||
return
|
||||
if record.state in {
|
||||
WarmupState.SUCCEEDED,
|
||||
WarmupState.FAILED,
|
||||
WarmupState.TIMED_OUT,
|
||||
}:
|
||||
return
|
||||
record.state = state
|
||||
record.reason_code = reason
|
||||
record.completed_at = self._clock()
|
||||
if state in {WarmupState.FAILED, WarmupState.TIMED_OUT}:
|
||||
self._set(
|
||||
phase=StartupPhase.OPTIONAL_WARMUP,
|
||||
state=StartupState.DEGRADED,
|
||||
reason_code=reason,
|
||||
)
|
||||
elif self._state is StartupState.READY:
|
||||
self._set(
|
||||
phase=StartupPhase.OPTIONAL_WARMUP,
|
||||
state=StartupState.READY,
|
||||
reason_code=reason,
|
||||
)
|
||||
|
||||
def snapshot(self) -> StartupOutcome:
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
warmups = []
|
||||
for name in sorted(self._warmups):
|
||||
record = self._warmups[name]
|
||||
started_at = record.started_at
|
||||
completed_at = record.completed_at
|
||||
if started_at is None:
|
||||
duration = 0.0
|
||||
else:
|
||||
duration = (
|
||||
completed_at if completed_at is not None else now
|
||||
) - started_at
|
||||
warmups.append(
|
||||
WarmupOutcome(
|
||||
name=record.name,
|
||||
state=record.state,
|
||||
reason_code=record.reason_code,
|
||||
timeout_ms=_bounded_ms(record.timeout_sec),
|
||||
duration_ms=_bounded_ms(duration),
|
||||
)
|
||||
)
|
||||
ready = self._state in {StartupState.READY, StartupState.DEGRADED}
|
||||
return StartupOutcome(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
phase=self._phase,
|
||||
state=self._state,
|
||||
reason_code=self._reason_code,
|
||||
ready=ready,
|
||||
degraded=self._state is StartupState.DEGRADED,
|
||||
fatal=self._state is StartupState.FATAL,
|
||||
attempt=self._attempt,
|
||||
max_attempts=self._max_attempts,
|
||||
elapsed_ms=_bounded_ms(now - self._started_at),
|
||||
phase_elapsed_ms=_bounded_ms(now - self._phase_started_at),
|
||||
ready_elapsed_ms=(
|
||||
_bounded_ms(now - self._ready_at)
|
||||
if self._ready_at is not None
|
||||
else None
|
||||
),
|
||||
warmups=tuple(warmups),
|
||||
)
|
||||
|
||||
|
||||
_LIFECYCLE = StartupLifecycle()
|
||||
|
||||
|
||||
def get_startup_outcome() -> StartupOutcome:
|
||||
return _LIFECYCLE.snapshot()
|
||||
|
||||
|
||||
def get_startup_diagnostics() -> dict[str, Any]:
|
||||
return get_startup_outcome().to_diagnostics()
|
||||
|
||||
|
||||
def mark_required_initialization_started() -> None:
|
||||
_LIFECYCLE.mark_required_initialization_started()
|
||||
|
||||
|
||||
def mark_host_waiting(*, attempt: int, max_attempts: int) -> None:
|
||||
_LIFECYCLE.mark_host_waiting(attempt=attempt, max_attempts=max_attempts)
|
||||
|
||||
|
||||
def mark_route_registration_started(*, attempt: int = 0, max_attempts: int = 0) -> None:
|
||||
_LIFECYCLE.mark_route_registration_started(
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
|
||||
|
||||
def mark_startup_ready(phase: str = "routes") -> None:
|
||||
"""Compatibility facade that reaches the required ready transition."""
|
||||
|
||||
_ = phase
|
||||
outcome = _LIFECYCLE.snapshot()
|
||||
if outcome.state is StartupState.STARTING:
|
||||
_LIFECYCLE.mark_required_initialization_started()
|
||||
_LIFECYCLE.mark_route_registration_started()
|
||||
_LIFECYCLE.mark_ready()
|
||||
return
|
||||
if outcome.state is StartupState.REGISTERING_ROUTES:
|
||||
_LIFECYCLE.mark_ready()
|
||||
return
|
||||
if outcome.state in {StartupState.READY, StartupState.DEGRADED}:
|
||||
return
|
||||
raise StartupTransitionError("INVALID_TRANSITION")
|
||||
|
||||
|
||||
def mark_startup_fatal(
|
||||
phase: str,
|
||||
exc: BaseException | None = None,
|
||||
*,
|
||||
reason_code: StartupReason | str | None = None,
|
||||
) -> None:
|
||||
"""Record a stable fatal classification without retaining ``exc``."""
|
||||
|
||||
_ = exc
|
||||
phase_map = {
|
||||
"package_import": StartupPhase.PACKAGE_IMPORT,
|
||||
"required_startup": StartupPhase.REQUIRED_INITIALIZATION,
|
||||
"required_initialization": StartupPhase.REQUIRED_INITIALIZATION,
|
||||
"route_registration": StartupPhase.ROUTE_REGISTRATION,
|
||||
"route_registration_retry": StartupPhase.HOST_WAIT,
|
||||
"host_wait": StartupPhase.HOST_WAIT,
|
||||
}
|
||||
resolved_phase = phase_map.get(str(phase), StartupPhase.REQUIRED_INITIALIZATION)
|
||||
if reason_code is None:
|
||||
default_reasons = {
|
||||
StartupPhase.PACKAGE_IMPORT: StartupReason.BOOTSTRAP_IMPORT_FAILED,
|
||||
StartupPhase.REQUIRED_INITIALIZATION: (
|
||||
StartupReason.REQUIRED_INITIALIZATION_FAILED
|
||||
),
|
||||
StartupPhase.ROUTE_REGISTRATION: (StartupReason.ROUTE_REGISTRATION_FAILED),
|
||||
StartupPhase.HOST_WAIT: StartupReason.RETRY_EXHAUSTED,
|
||||
}
|
||||
resolved_reason = default_reasons[resolved_phase]
|
||||
else:
|
||||
resolved_reason = (
|
||||
reason_code
|
||||
if isinstance(reason_code, StartupReason)
|
||||
else StartupReason(str(reason_code))
|
||||
)
|
||||
_LIFECYCLE.mark_fatal(
|
||||
phase=resolved_phase,
|
||||
reason_code=resolved_reason,
|
||||
)
|
||||
|
||||
|
||||
def mark_bootstrap_import_failed(exc: BaseException) -> None:
|
||||
mark_startup_fatal(
|
||||
"package_import",
|
||||
exc,
|
||||
reason_code=StartupReason.BOOTSTRAP_IMPORT_FAILED,
|
||||
)
|
||||
|
||||
|
||||
def mark_retry_exhausted() -> None:
|
||||
_LIFECYCLE.mark_retry_exhausted()
|
||||
|
||||
|
||||
def start_optional_warmups(specs: Iterable[WarmupSpec]) -> None:
|
||||
for name, fn, timeout_sec in tuple(specs or ()):
|
||||
_start_optional_warmup(str(name), fn, float(timeout_sec))
|
||||
|
||||
|
||||
def reset_startup_lifecycle_for_tests() -> None:
|
||||
_LIFECYCLE.reset()
|
||||
|
||||
|
||||
def _start_optional_warmup(
|
||||
name: str, fn: Callable[[], Any], timeout_sec: float
|
||||
) -> None:
|
||||
should_start, generation, safe_name = _LIFECYCLE.begin_warmup(name, timeout_sec)
|
||||
if not should_start:
|
||||
return
|
||||
monitor = threading.Thread(
|
||||
target=_warmup_monitor,
|
||||
args=(safe_name, generation, fn, max(0.01, min(timeout_sec, 60.0))),
|
||||
name=f"openclaw-warmup-monitor-{safe_name}",
|
||||
daemon=True,
|
||||
)
|
||||
try:
|
||||
monitor.start()
|
||||
except Exception:
|
||||
_LIFECYCLE.finish_warmup(
|
||||
safe_name,
|
||||
generation,
|
||||
state=WarmupState.FAILED,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _warmup_monitor(
|
||||
name: str,
|
||||
generation: int,
|
||||
fn: Callable[[], Any],
|
||||
timeout_sec: float,
|
||||
) -> None:
|
||||
done = threading.Event()
|
||||
result: dict[str, bool] = {}
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
fn()
|
||||
result["ok"] = True
|
||||
except Exception:
|
||||
# SECURITY: never retain or log arbitrary exception content.
|
||||
result["ok"] = False
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
_LIFECYCLE.mark_warmup_running(name, generation)
|
||||
worker = threading.Thread(
|
||||
target=_worker,
|
||||
name=f"openclaw-warmup-{name}",
|
||||
daemon=True,
|
||||
)
|
||||
try:
|
||||
worker.start()
|
||||
except Exception as exc:
|
||||
_LIFECYCLE.finish_warmup(
|
||||
name,
|
||||
generation,
|
||||
state=WarmupState.FAILED,
|
||||
)
|
||||
logger.warning(
|
||||
"Optional startup warmup worker could not start "
|
||||
"(component=%s, error_type=%s)",
|
||||
name,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return
|
||||
|
||||
if not done.wait(timeout=timeout_sec):
|
||||
_LIFECYCLE.finish_warmup(
|
||||
name,
|
||||
generation,
|
||||
state=WarmupState.TIMED_OUT,
|
||||
)
|
||||
logger.warning(
|
||||
"Optional startup warmup timed out (component=%s, reason_code=%s)",
|
||||
name,
|
||||
StartupReason.WARMUP_TIMED_OUT.value,
|
||||
)
|
||||
return
|
||||
|
||||
if result.get("ok"):
|
||||
_LIFECYCLE.finish_warmup(
|
||||
name,
|
||||
generation,
|
||||
state=WarmupState.SUCCEEDED,
|
||||
)
|
||||
logger.info("Optional startup warmup completed (component=%s)", name)
|
||||
return
|
||||
|
||||
_LIFECYCLE.finish_warmup(
|
||||
name,
|
||||
generation,
|
||||
state=WarmupState.FAILED,
|
||||
)
|
||||
logger.warning(
|
||||
"Optional startup warmup failed (component=%s, reason_code=%s)",
|
||||
name,
|
||||
StartupReason.WARMUP_FAILED.value,
|
||||
)
|
||||
@@ -0,0 +1,623 @@
|
||||
"""
|
||||
Route/bootstrap orchestration implementation owner.
|
||||
|
||||
Keeps __init__.py thin while preserving startup behavior and fallback handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
_routes_registered = False
|
||||
_registration_condition = threading.Condition(threading.RLock())
|
||||
_registration_inflight = False
|
||||
_registration_started = False
|
||||
_registration_error: Exception | None = None
|
||||
_registration_retry_thread: threading.Thread | None = None
|
||||
_registration_generation = 0
|
||||
_REGISTRATION_MAX_ATTEMPTS = 10
|
||||
_REGISTRATION_INITIAL_DELAY_SEC = 2.0
|
||||
|
||||
|
||||
def _resolve_optional_warmup_timeout_sec() -> float:
|
||||
raw = (
|
||||
os.environ.get("OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC")
|
||||
or os.environ.get("MOLTBOT_STARTUP_WARMUP_TIMEOUT_SEC")
|
||||
or "5"
|
||||
)
|
||||
try:
|
||||
return max(0.1, min(float(raw), 60.0))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
|
||||
def _warm_model_inventory_snapshot() -> None:
|
||||
from ..preflight import get_model_inventory_snapshot
|
||||
|
||||
get_model_inventory_snapshot(trigger_refresh=True)
|
||||
|
||||
|
||||
def _build_optional_startup_warmups():
|
||||
timeout_sec = _resolve_optional_warmup_timeout_sec()
|
||||
return [
|
||||
("model_inventory", _warm_model_inventory_snapshot, timeout_sec),
|
||||
]
|
||||
|
||||
|
||||
def _mark_startup_ready_and_start_warmups() -> None:
|
||||
from .lifecycle import mark_startup_ready, start_optional_warmups
|
||||
|
||||
# Required readiness is part of successful route registration and must not be
|
||||
# hidden behind the optional warmup boundary.
|
||||
mark_startup_ready("routes")
|
||||
try:
|
||||
start_optional_warmups(_build_optional_startup_warmups())
|
||||
except Exception as exc:
|
||||
# IMPORTANT: optional warmup diagnostics must not undo successful route startup.
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(
|
||||
"Optional startup warmups could not be started (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _mark_startup_fatal(
|
||||
phase: str,
|
||||
exc: BaseException,
|
||||
*,
|
||||
reason_code=None,
|
||||
) -> None:
|
||||
try:
|
||||
from .lifecycle import mark_startup_fatal
|
||||
|
||||
mark_startup_fatal(phase, exc, reason_code=reason_code)
|
||||
except Exception as diagnostics_exc:
|
||||
# IMPORTANT: preserve the original bootstrap exception even if diagnostics fail.
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(
|
||||
"Startup diagnostics update failed (error_type=%s)",
|
||||
type(diagnostics_exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _mark_required_initialization_started() -> None:
|
||||
from .lifecycle import mark_required_initialization_started
|
||||
|
||||
mark_required_initialization_started()
|
||||
|
||||
|
||||
def _mark_host_waiting(*, attempt: int, max_attempts: int) -> None:
|
||||
from .lifecycle import mark_host_waiting
|
||||
|
||||
mark_host_waiting(attempt=attempt, max_attempts=max_attempts)
|
||||
|
||||
|
||||
def _mark_route_registration_started(
|
||||
*, attempt: int = 0, max_attempts: int = 0
|
||||
) -> None:
|
||||
from .lifecycle import mark_route_registration_started
|
||||
|
||||
mark_route_registration_started(
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
|
||||
|
||||
def _load_plugin_shutdown_registrars():
|
||||
"""Load optional startup registrars behind one patchable compatibility seam."""
|
||||
|
||||
from ..plugins.builtin import register_all
|
||||
from ..runtime_lifecycle import register_shutdown_hooks
|
||||
|
||||
return register_shutdown_hooks, register_all
|
||||
|
||||
|
||||
def _register_plugins_and_shutdown_hooks() -> None:
|
||||
# R67: Best-effort process shutdown hook and built-in plugin registration.
|
||||
try:
|
||||
register_shutdown_hooks, register_all = _load_plugin_shutdown_registrars()
|
||||
except ImportError as exc:
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(
|
||||
"Optional startup registrars unavailable (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
return
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw")
|
||||
for component, registrar in (
|
||||
("shutdown_hooks", register_shutdown_hooks),
|
||||
("builtin_plugins", register_all),
|
||||
):
|
||||
try:
|
||||
registrar()
|
||||
except Exception as exc:
|
||||
# IMPORTANT: these optional steps are independent. Keep startup available,
|
||||
# do not echo exception content, and do not catch BaseException cancellation.
|
||||
logger.error(
|
||||
"Optional startup registrar failed (component=%s, error_type=%s)",
|
||||
component,
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _initialize_registries_and_security_gate() -> None:
|
||||
# R63/R84: Initialize Service & Module Registries.
|
||||
try:
|
||||
from ..modules import ModuleCapability, ModuleRegistry, enable_module
|
||||
from ..registry import SVC_RUNTIME_CONFIG, ServiceRegistry
|
||||
from ..runtime_config import get_config
|
||||
|
||||
config = get_config()
|
||||
ServiceRegistry.register(SVC_RUNTIME_CONFIG, config)
|
||||
|
||||
from ..posture.effective import (
|
||||
get_effective_security_posture,
|
||||
resolve_effective_security_posture,
|
||||
)
|
||||
|
||||
posture = get_effective_security_posture(required=False)
|
||||
if posture is None:
|
||||
# Direct compatibility/test invocation does not own process installation.
|
||||
posture = resolve_effective_security_posture()
|
||||
|
||||
# Always-on modules
|
||||
enable_module(ModuleCapability.CORE)
|
||||
enable_module(ModuleCapability.SECURITY)
|
||||
enable_module(ModuleCapability.OBSERVABILITY)
|
||||
|
||||
# S50: initialize durable idempotency storage early.
|
||||
from ..idempotency_store import IdempotencyStore
|
||||
from ..state_dir import get_state_dir
|
||||
|
||||
db_path = os.path.join(get_state_dir(), "idempotency.db")
|
||||
# CRITICAL: pass db_path as keyword (first positional arg is backend object).
|
||||
IdempotencyStore().configure_durable(db_path=db_path, strict_mode=True)
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"IdempotencyStore durable backend configured (strict_mode=True)"
|
||||
)
|
||||
|
||||
if config.bridge_enabled:
|
||||
enable_module(ModuleCapability.BRIDGE)
|
||||
|
||||
# Core runtime modules stay enabled; runners decide active behavior.
|
||||
enable_module(ModuleCapability.SCHEDULER)
|
||||
enable_module(ModuleCapability.WEBHOOK)
|
||||
enable_module(ModuleCapability.CONNECTOR)
|
||||
|
||||
ModuleRegistry.lock()
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"Initialized modules: %s", ModuleRegistry.get_enabled_list()
|
||||
)
|
||||
|
||||
from ..security_gate import enforce_startup_gate
|
||||
|
||||
enforce_startup_gate(posture=posture)
|
||||
except Exception as exc:
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(
|
||||
"Required registry initialization failed (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
# CRITICAL: keep bootstrap fail-closed; swallowing startup gate errors
|
||||
# silently degrades security posture and can expose partial registration.
|
||||
raise
|
||||
|
||||
|
||||
def _do_full_registration(server) -> None:
|
||||
"""Register all OpenClaw routes including bridge/scheduler bindings."""
|
||||
from ..access_control import require_admin_token
|
||||
from ..parameter_lab_queue_receipt import (
|
||||
register_parameter_lab_queue_receipt_handler,
|
||||
)
|
||||
from ..plugins.async_bridge import run_async_in_sync_context
|
||||
from ..queue_submit import submit_prompt
|
||||
from ..route_bootstrap_contract import load_route_bootstrap_contract
|
||||
from ..scheduler.runner import get_scheduler_runner, start_scheduler
|
||||
from ..templates import get_template_service
|
||||
|
||||
# IMPORTANT: the contract owns its stable relative-import anchor; forwarding this
|
||||
# nested package resolves `..api` to the nonexistent `services.api` namespace.
|
||||
contract = load_route_bootstrap_contract()
|
||||
register_approval_routes = contract["register_approval_routes"]
|
||||
BridgeHandlers = contract["BridgeHandlers"]
|
||||
register_preset_routes = contract["register_preset_routes"]
|
||||
register_routes = contract["register_routes"]
|
||||
register_schedule_routes = contract["register_schedule_routes"]
|
||||
register_trigger_routes = contract["register_trigger_routes"]
|
||||
|
||||
register_routes(server)
|
||||
# CRITICAL: receipt promotion is required for exact Parameter Lab run ownership.
|
||||
register_parameter_lab_queue_receipt_handler(server)
|
||||
register_preset_routes(server.app)
|
||||
register_schedule_routes(server.app, require_admin_token_fn=require_admin_token)
|
||||
|
||||
class QueueSubmitService:
|
||||
def submit(self, job_req):
|
||||
tmpl_svc = get_template_service()
|
||||
workflow = tmpl_svc.render_template(job_req.template_id, job_req.inputs)
|
||||
|
||||
async def _do_submit():
|
||||
return await submit_prompt(
|
||||
workflow,
|
||||
client_id=job_req.session_id or "bridge",
|
||||
extra_data={
|
||||
"openclaw": {"trace_id": job_req.trace_id},
|
||||
# Legacy key kept for existing tooling that expects this blob.
|
||||
"moltbot": {"trace_id": job_req.trace_id},
|
||||
},
|
||||
source="bridge",
|
||||
trace_id=job_req.trace_id,
|
||||
)
|
||||
|
||||
return run_async_in_sync_context(_do_submit())
|
||||
|
||||
bridge_handlers = BridgeHandlers(submit_service=QueueSubmitService())
|
||||
_register_bridge_routes(server.app.router, bridge_handlers)
|
||||
|
||||
async def unified_submit_fn(
|
||||
template_id,
|
||||
inputs,
|
||||
trace_id,
|
||||
idempotency_key,
|
||||
delivery=None,
|
||||
source="unknown",
|
||||
):
|
||||
"""Submit function for scheduler and trigger-triggered runs."""
|
||||
# NOTE: Use IdempotencyStore API (check_and_record/update_prompt_id).
|
||||
# Avoid legacy get_store/get/set usage; wrong API here breaks route registration at runtime.
|
||||
from ..idempotency_store import IdempotencyStore
|
||||
from ..queue_submit import submit_prompt as _submit_prompt
|
||||
from ..templates import get_template_service as _get_template_service
|
||||
|
||||
store = IdempotencyStore()
|
||||
is_dup, existing_prompt_id = store.check_and_record(idempotency_key)
|
||||
if is_dup:
|
||||
return {"prompt_id": existing_prompt_id, "deduped": True}
|
||||
|
||||
tmpl_svc = _get_template_service()
|
||||
workflow = tmpl_svc.render_template(template_id, inputs)
|
||||
|
||||
result = await _submit_prompt(
|
||||
workflow,
|
||||
extra_data={
|
||||
"openclaw": {"trace_id": trace_id, "source": "automation"},
|
||||
"moltbot": {"trace_id": trace_id, "source": "automation"},
|
||||
},
|
||||
source=source,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
if result.get("prompt_id"):
|
||||
store.update_prompt_id(idempotency_key, result["prompt_id"])
|
||||
return result
|
||||
|
||||
runner = get_scheduler_runner()
|
||||
runner._submit_fn = unified_submit_fn
|
||||
start_scheduler()
|
||||
|
||||
register_trigger_routes(
|
||||
server.app,
|
||||
require_admin_token_fn=require_admin_token,
|
||||
submit_fn=unified_submit_fn,
|
||||
)
|
||||
register_approval_routes(
|
||||
server.app,
|
||||
require_admin_token_fn=require_admin_token,
|
||||
submit_fn=unified_submit_fn,
|
||||
)
|
||||
|
||||
|
||||
_BRIDGE_ROUTE_SPECS = (
|
||||
("add_post", "/moltbot/bridge/submit", "submit_handler"),
|
||||
("add_post", "/moltbot/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/moltbot/bridge/health", "health_handler"),
|
||||
("add_post", "/openclaw/bridge/submit", "submit_handler"),
|
||||
("add_post", "/openclaw/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/openclaw/bridge/health", "health_handler"),
|
||||
("add_post", "/api/moltbot/bridge/submit", "submit_handler"),
|
||||
("add_post", "/api/moltbot/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/api/moltbot/bridge/health", "health_handler"),
|
||||
("add_post", "/api/openclaw/bridge/submit", "submit_handler"),
|
||||
("add_post", "/api/openclaw/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/api/openclaw/bridge/health", "health_handler"),
|
||||
)
|
||||
|
||||
|
||||
def _register_bridge_routes(router, bridge_handlers) -> None:
|
||||
# IMPORTANT: keep bridge route registration table-driven.
|
||||
# Missing one alias path here silently breaks one control-plane surface while
|
||||
# leaving the rest apparently healthy, which is hard to diagnose during startup.
|
||||
for method_name, path, handler_name in _BRIDGE_ROUTE_SPECS:
|
||||
registrar = getattr(router, method_name, None)
|
||||
if registrar is None:
|
||||
continue
|
||||
try:
|
||||
registrar(path, getattr(bridge_handlers, handler_name))
|
||||
except RuntimeError:
|
||||
if path.startswith("/api/"):
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _resolve_prompt_server():
|
||||
ps_mod = sys.modules.get("server")
|
||||
prompt_server = getattr(ps_mod, "PromptServer", None) if ps_mod else None
|
||||
return getattr(prompt_server, "instance", None) if prompt_server else None
|
||||
|
||||
|
||||
def reset_route_bootstrap_for_tests() -> None:
|
||||
"""Invalidate background ownership and reset the route bootstrap seam."""
|
||||
|
||||
global _routes_registered
|
||||
global _registration_error
|
||||
global _registration_generation
|
||||
global _registration_inflight
|
||||
global _registration_retry_thread
|
||||
global _registration_started
|
||||
with _registration_condition:
|
||||
_registration_generation += 1
|
||||
_routes_registered = False
|
||||
_registration_inflight = False
|
||||
_registration_started = False
|
||||
_registration_error = None
|
||||
_registration_retry_thread = None
|
||||
_registration_condition.notify_all()
|
||||
try:
|
||||
from ..posture.effective import reset_effective_security_posture_for_tests
|
||||
|
||||
reset_effective_security_posture_for_tests()
|
||||
except ImportError:
|
||||
# Dependency-light test/import mode may omit the posture module.
|
||||
pass
|
||||
|
||||
|
||||
def _store_registration_success(*, generation: int | None = None) -> bool:
|
||||
global _routes_registered
|
||||
global _registration_error
|
||||
global _registration_inflight
|
||||
global _registration_started
|
||||
with _registration_condition:
|
||||
if generation is not None and generation != _registration_generation:
|
||||
return False
|
||||
_routes_registered = True
|
||||
_registration_inflight = False
|
||||
_registration_started = True
|
||||
_registration_error = None
|
||||
_registration_condition.notify_all()
|
||||
return True
|
||||
|
||||
|
||||
def _store_registration_failure(
|
||||
exc: Exception,
|
||||
*,
|
||||
generation: int | None = None,
|
||||
) -> bool:
|
||||
global _registration_error
|
||||
global _registration_inflight
|
||||
global _registration_started
|
||||
with _registration_condition:
|
||||
if generation is not None and generation != _registration_generation:
|
||||
return False
|
||||
_registration_inflight = False
|
||||
_registration_started = True
|
||||
_registration_error = exc
|
||||
_registration_condition.notify_all()
|
||||
return True
|
||||
|
||||
|
||||
def _run_registration_retry_loop(
|
||||
*,
|
||||
max_attempts: int = _REGISTRATION_MAX_ATTEMPTS,
|
||||
initial_delay: float = _REGISTRATION_INITIAL_DELAY_SEC,
|
||||
sleep_fn: Callable[[float], None] = time.sleep,
|
||||
generation: int | None = None,
|
||||
) -> None:
|
||||
"""Run the sole bounded host-wait owner with explicit terminal outcomes."""
|
||||
|
||||
global _registration_retry_thread
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw")
|
||||
with _registration_condition:
|
||||
owner_generation = (
|
||||
_registration_generation if generation is None else generation
|
||||
)
|
||||
|
||||
delay = max(0.0, float(initial_delay))
|
||||
try:
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
with _registration_condition:
|
||||
if owner_generation != _registration_generation:
|
||||
return
|
||||
|
||||
try:
|
||||
server = _resolve_prompt_server()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal("route_registration", exc)
|
||||
_store_registration_failure(exc, generation=owner_generation)
|
||||
logger.error(
|
||||
"PromptServer resolution failed (attempt=%s, error_type=%s)",
|
||||
attempt,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return
|
||||
|
||||
if server is not None:
|
||||
try:
|
||||
_mark_route_registration_started(
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
_do_full_registration(server)
|
||||
_mark_startup_ready_and_start_warmups()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal("route_registration", exc)
|
||||
_store_registration_failure(exc, generation=owner_generation)
|
||||
logger.error(
|
||||
"Route registration failed (attempt=%s, error_type=%s)",
|
||||
attempt,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return
|
||||
_store_registration_success(generation=owner_generation)
|
||||
logger.info(
|
||||
"Routes registered successfully (attempt=%s)",
|
||||
attempt,
|
||||
)
|
||||
return
|
||||
|
||||
_mark_host_waiting(attempt=attempt, max_attempts=max_attempts)
|
||||
logger.debug("PromptServer not ready (attempt=%s)", attempt)
|
||||
if attempt < max_attempts:
|
||||
sleep_fn(delay)
|
||||
delay = min(delay * 1.5, 30.0)
|
||||
|
||||
failure = RuntimeError("route registration retry exhausted")
|
||||
_mark_startup_fatal(
|
||||
"host_wait",
|
||||
failure,
|
||||
reason_code="retry_exhausted",
|
||||
)
|
||||
_store_registration_failure(failure, generation=owner_generation)
|
||||
logger.error(
|
||||
"Route registration retry exhausted (attempts=%s)",
|
||||
max_attempts,
|
||||
)
|
||||
finally:
|
||||
with _registration_condition:
|
||||
if owner_generation == _registration_generation:
|
||||
current = threading.current_thread()
|
||||
if _registration_retry_thread is current:
|
||||
_registration_retry_thread = None
|
||||
_registration_condition.notify_all()
|
||||
|
||||
|
||||
def _start_registration_retry_loop() -> None:
|
||||
"""Start at most one background host-wait owner."""
|
||||
|
||||
global _registration_retry_thread
|
||||
with _registration_condition:
|
||||
existing = _registration_retry_thread
|
||||
if existing is not None and existing.is_alive():
|
||||
return
|
||||
generation = _registration_generation
|
||||
thread = threading.Thread(
|
||||
target=_run_registration_retry_loop,
|
||||
kwargs={"generation": generation},
|
||||
name="openclaw-route-retry",
|
||||
daemon=True,
|
||||
)
|
||||
_registration_retry_thread = thread
|
||||
thread.start()
|
||||
|
||||
|
||||
def register_routes_once() -> None:
|
||||
"""Initialize and register routes through one process-wide bootstrap owner."""
|
||||
|
||||
global _registration_inflight
|
||||
global _registration_started
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw")
|
||||
|
||||
# CRITICAL: one condition owns initialization, registration, retry creation, and
|
||||
# terminal error replay. Independent flags reintroduce duplicate side effects.
|
||||
with _registration_condition:
|
||||
if _routes_registered:
|
||||
return
|
||||
if _registration_error is not None:
|
||||
raise _registration_error
|
||||
if _registration_started:
|
||||
while _registration_inflight:
|
||||
_registration_condition.wait()
|
||||
if _registration_error is not None:
|
||||
raise _registration_error
|
||||
return
|
||||
_registration_started = True
|
||||
_registration_inflight = True
|
||||
generation = _registration_generation
|
||||
|
||||
try:
|
||||
from ..posture.effective import get_or_create_effective_security_posture
|
||||
|
||||
# CRITICAL: this required startup owner installs process-static posture once.
|
||||
# Direct helper/API invocations resolve ephemeral snapshots instead.
|
||||
get_or_create_effective_security_posture()
|
||||
_mark_required_initialization_started()
|
||||
try:
|
||||
_register_plugins_and_shutdown_hooks()
|
||||
_initialize_registries_and_security_gate()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal("required_initialization", exc)
|
||||
_store_registration_failure(exc, generation=generation)
|
||||
logger.error(
|
||||
"Required startup initialization failed (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
server = _resolve_prompt_server()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal("route_registration", exc)
|
||||
_store_registration_failure(exc, generation=generation)
|
||||
logger.error(
|
||||
"Initial PromptServer resolution failed (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
if server is None:
|
||||
_mark_host_waiting(
|
||||
attempt=0,
|
||||
max_attempts=_REGISTRATION_MAX_ATTEMPTS,
|
||||
)
|
||||
try:
|
||||
_start_registration_retry_loop()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal(
|
||||
"host_wait",
|
||||
exc,
|
||||
reason_code="retry_exhausted",
|
||||
)
|
||||
_store_registration_failure(exc, generation=generation)
|
||||
logger.error(
|
||||
"Route registration retry owner failed to start (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
logger.info(
|
||||
"PromptServer not ready; route registration retry owner started"
|
||||
)
|
||||
return
|
||||
|
||||
_mark_route_registration_started()
|
||||
try:
|
||||
_do_full_registration(server)
|
||||
_mark_startup_ready_and_start_warmups()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal("route_registration", exc)
|
||||
_store_registration_failure(exc, generation=generation)
|
||||
logger.error(
|
||||
"Initial route registration failed (error_type=%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
# CRITICAL: only host availability is retryable. Broken route
|
||||
# registration remains fail-closed and replays the same error.
|
||||
raise
|
||||
|
||||
_store_registration_success(generation=generation)
|
||||
logger.info("Routes registered successfully on initial attempt")
|
||||
except BaseException:
|
||||
with _registration_condition:
|
||||
if (
|
||||
generation == _registration_generation
|
||||
and _registration_error is None
|
||||
and not _routes_registered
|
||||
):
|
||||
_registration_started = False
|
||||
_registration_inflight = False
|
||||
_registration_condition.notify_all()
|
||||
raise
|
||||
finally:
|
||||
with _registration_condition:
|
||||
if generation == _registration_generation:
|
||||
_registration_inflight = False
|
||||
_registration_condition.notify_all()
|
||||
@@ -26,6 +26,15 @@ from .runtime_profile import get_runtime_profile
|
||||
API_VERSION = 1
|
||||
|
||||
|
||||
def _get_installed_posture():
|
||||
try:
|
||||
from .effective_security_posture import get_effective_security_posture
|
||||
|
||||
return get_effective_security_posture(required=False)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_control_plane_info() -> dict:
|
||||
"""Build control-plane status for capabilities response."""
|
||||
try:
|
||||
@@ -33,9 +42,14 @@ def _get_control_plane_info() -> dict:
|
||||
|
||||
from .control_plane import get_blocked_surfaces, resolve_control_plane_mode
|
||||
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
mode = resolve_control_plane_mode(profile)
|
||||
blocked = get_blocked_surfaces(profile, mode)
|
||||
posture = _get_installed_posture()
|
||||
profile = (
|
||||
posture.deployment_profile
|
||||
if posture is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
)
|
||||
mode = resolve_control_plane_mode(profile, posture=posture)
|
||||
blocked = get_blocked_surfaces(profile, mode, posture=posture)
|
||||
info = {
|
||||
"mode": mode.value,
|
||||
"blocked_surfaces": [sid for sid, _ in blocked],
|
||||
@@ -59,9 +73,14 @@ def get_capabilities() -> dict:
|
||||
Return capability surface for frontend probing.
|
||||
"""
|
||||
cp_info = _get_control_plane_info()
|
||||
posture = _get_installed_posture()
|
||||
result = {
|
||||
"api_version": API_VERSION,
|
||||
"runtime_profile": get_runtime_profile().value,
|
||||
"runtime_profile": (
|
||||
posture.runtime_profile
|
||||
if posture is not None
|
||||
else get_runtime_profile().value
|
||||
),
|
||||
"control_plane": cp_info,
|
||||
"pack": {
|
||||
"name": PACK_NAME,
|
||||
|
||||
@@ -6,6 +6,7 @@ Parses ComfyUI /history/{prompt_id} responses and extracts image output metadata
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import unicodedata
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -24,7 +25,20 @@ COMFYUI_URL = (
|
||||
)
|
||||
HISTORY_TIMEOUT = 5
|
||||
PREVIEWABLE_MEDIA_TYPES = ("images", "video", "audio", "3d", "text")
|
||||
THREE_D_EXTENSIONS = (".obj", ".fbx", ".gltf", ".glb", ".usdz")
|
||||
THREE_D_EXTENSIONS = (
|
||||
".obj",
|
||||
".fbx",
|
||||
".gltf",
|
||||
".glb",
|
||||
".stl",
|
||||
".ply",
|
||||
".spz",
|
||||
".splat",
|
||||
".ksplat",
|
||||
".usdz",
|
||||
)
|
||||
ADVANCED_3D_RESULT_MAX_ENTRIES = 8
|
||||
ADVANCED_3D_RESULT_PATH_MAX_LENGTH = 1024
|
||||
TEXT_PREVIEW_MAX_LENGTH = 1024
|
||||
FILE_TEXT_EXTENSIONS = frozenset(
|
||||
{"txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log"}
|
||||
@@ -97,6 +111,58 @@ def _has_unsafe_file_characters(value: str) -> bool:
|
||||
return any(ord(char) < 32 or ord(char) == 127 for char in value)
|
||||
|
||||
|
||||
def _has_unsafe_advanced_3d_characters(value: str) -> bool:
|
||||
return any(unicodedata.category(char) in {"Cc", "Cf", "Cs"} for char in value)
|
||||
|
||||
|
||||
def _normalize_advanced_3d_result(result: Any) -> dict[str, Any] | None:
|
||||
if (
|
||||
not isinstance(result, list)
|
||||
or not result
|
||||
or len(result) > ADVANCED_3D_RESULT_MAX_ENTRIES
|
||||
):
|
||||
return None
|
||||
|
||||
raw_path = result[0]
|
||||
if (
|
||||
not isinstance(raw_path, str)
|
||||
or len(raw_path) > ADVANCED_3D_RESULT_PATH_MAX_LENGTH
|
||||
):
|
||||
return None
|
||||
|
||||
normalized_path = raw_path.replace("\\", "/")
|
||||
if (
|
||||
not normalized_path
|
||||
or len(normalized_path) > ADVANCED_3D_RESULT_PATH_MAX_LENGTH
|
||||
or _has_unsafe_advanced_3d_characters(normalized_path)
|
||||
or normalized_path.startswith("/")
|
||||
or any(marker in normalized_path for marker in (":", "%", "?", "#"))
|
||||
):
|
||||
return None
|
||||
|
||||
segments = normalized_path.split("/")
|
||||
if any(
|
||||
not segment or segment in {".", ".."} or segment != segment.strip()
|
||||
for segment in segments
|
||||
):
|
||||
return None
|
||||
|
||||
filename = segments[-1]
|
||||
if not _has_3d_extension(filename):
|
||||
return None
|
||||
|
||||
# SECURITY: result metadata is untrusted and may contain private host state.
|
||||
# Inspect only the validated path at index zero; never project later entries.
|
||||
return normalize_history_output_ref(
|
||||
{
|
||||
"filename": filename,
|
||||
"subfolder": "/".join(segments[:-1]),
|
||||
"type": "output",
|
||||
},
|
||||
"3d",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_file_text_ref(output_ref: Any) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(output_ref, dict):
|
||||
return None
|
||||
@@ -275,6 +341,10 @@ def extract_output_refs(history_item: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if normalized:
|
||||
results.append(normalized)
|
||||
|
||||
advanced_3d_ref = _normalize_advanced_3d_result(node_output.get("result"))
|
||||
if advanced_3d_ref:
|
||||
results.append(advanced_3d_ref)
|
||||
|
||||
file_refs = node_output.get("files")
|
||||
if isinstance(file_refs, list) and len(file_refs) <= FILE_OUTPUT_MAX_REFS:
|
||||
for ref in file_refs:
|
||||
|
||||
@@ -19,16 +19,43 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
META_BLOCK_TAG = "openclaw-compat-matrix-meta"
|
||||
DEFAULT_WARN_AGE_DAYS = 30
|
||||
DEFAULT_MAX_AGE_DAYS = 45
|
||||
ANCHOR_KEYS = ("comfyui", "comfyui_frontend", "desktop")
|
||||
CURRENT_SCHEMA_VERSION = 2
|
||||
ANCHOR_KEYS = ("comfyui", "comfyui_frontend", "desktop", "comfy_desktop")
|
||||
DEFAULT_HOST_SURFACES: dict[str, dict[str, Any]] = {
|
||||
"desktop": {
|
||||
"generation": "legacy_fixed_bundle",
|
||||
"anchor_key": "desktop",
|
||||
"hosted_version_mode": "fixed",
|
||||
"core_version": "0.22.3",
|
||||
"frontend_version": "1.43.18",
|
||||
},
|
||||
"comfy_desktop": {
|
||||
"generation": "managed_install",
|
||||
"anchor_key": "comfy_desktop",
|
||||
"hosted_version_mode": "installation_specific",
|
||||
"core_version": None,
|
||||
"frontend_version": None,
|
||||
},
|
||||
}
|
||||
|
||||
META_BLOCK_RE = re.compile(
|
||||
r"```" + re.escape(META_BLOCK_TAG) + r"\s*\n(?P<body>.*?)\n```",
|
||||
re.DOTALL,
|
||||
)
|
||||
SEMVER_RE = re.compile(r"(?P<version>\d+\.\d+\.\d+)")
|
||||
COMFYUI_ANCHOR_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{7,40}\s+\(v[^\s/]+\s+/\s+pyproject\s+\d+\.\d+\.\d+\)$"
|
||||
)
|
||||
FRONTEND_ANCHOR_RE = re.compile(
|
||||
r"^\d+\.\d+\.\d+\s+\([0-9a-fA-F]{7,40}\s+/\s+v[^\s)]+\)$"
|
||||
)
|
||||
DESKTOP_ANCHOR_RE = re.compile(
|
||||
r"^(?P<desktop>\d+\.\d+\.\d+)\s+\(core\s+(?P<core>\d+\.\d+\.\d+)\s+/\s+frontend\s+(?P<frontend>\d+\.\d+\.\d+)\)$"
|
||||
)
|
||||
COMFY_DESKTOP_ANCHOR_RE = re.compile(
|
||||
r"^(?P<desktop>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s+"
|
||||
r"\((?P<revision>[0-9a-fA-F]{7,40})\s+/\s+(?P<describe>v[^\s)]+)\)$"
|
||||
)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
@@ -54,7 +81,7 @@ def _json_hash(payload: Any) -> str:
|
||||
def _default_metadata() -> Dict[str, Any]:
|
||||
today = _today_iso()
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"schema_version": CURRENT_SCHEMA_VERSION,
|
||||
"matrix_version": "v0.2.1",
|
||||
"last_validated_date": today,
|
||||
"policy": {
|
||||
@@ -62,6 +89,7 @@ def _default_metadata() -> Dict[str, Any]:
|
||||
"max_age_days": DEFAULT_MAX_AGE_DAYS,
|
||||
},
|
||||
"anchors": {key: "unknown" for key in ANCHOR_KEYS},
|
||||
"host_surfaces": copy.deepcopy(DEFAULT_HOST_SURFACES),
|
||||
"evidence": {
|
||||
"evidence_id": f"compat-matrix-{today.replace('-', '')}",
|
||||
"updated_at": _utc_now().isoformat(),
|
||||
@@ -149,7 +177,14 @@ def validate_metadata(
|
||||
}
|
||||
|
||||
schema_version = metadata.get("schema_version")
|
||||
if schema_version != 1:
|
||||
if schema_version == 1:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_SCHEMA_UPGRADE_REQUIRED",
|
||||
"message": "schema_version 1 must be refreshed to schema_version 2",
|
||||
}
|
||||
)
|
||||
elif schema_version != CURRENT_SCHEMA_VERSION:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_SCHEMA_VERSION",
|
||||
@@ -205,6 +240,20 @@ def validate_metadata(
|
||||
{"code": "R90_META_ANCHORS", "message": "Missing anchors object"}
|
||||
)
|
||||
else:
|
||||
unknown_anchor_keys = sorted(set(anchors) - set(ANCHOR_KEYS))
|
||||
for key in unknown_anchor_keys:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_ANCHOR_UNKNOWN",
|
||||
"message": f"Unknown anchor key: {key}",
|
||||
}
|
||||
)
|
||||
anchor_patterns = {
|
||||
"comfyui": COMFYUI_ANCHOR_RE,
|
||||
"comfyui_frontend": FRONTEND_ANCHOR_RE,
|
||||
"desktop": DESKTOP_ANCHOR_RE,
|
||||
"comfy_desktop": COMFY_DESKTOP_ANCHOR_RE,
|
||||
}
|
||||
for key in ANCHOR_KEYS:
|
||||
if key not in anchors:
|
||||
violations.append(
|
||||
@@ -213,6 +262,84 @@ def validate_metadata(
|
||||
"message": f"Missing anchors.{key}",
|
||||
}
|
||||
)
|
||||
elif not isinstance(anchors[key], str) or not anchors[key].strip():
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_ANCHOR_INVALID",
|
||||
"message": f"Invalid anchors.{key}",
|
||||
}
|
||||
)
|
||||
elif anchors[key].strip() == "unknown":
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_ANCHOR_UNRESOLVED",
|
||||
"message": f"Unresolved anchors.{key}",
|
||||
}
|
||||
)
|
||||
elif anchor_patterns[key].match(anchors[key].strip()) is None:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_ANCHOR_FORMAT",
|
||||
"message": f"Malformed anchors.{key}",
|
||||
}
|
||||
)
|
||||
|
||||
if schema_version == CURRENT_SCHEMA_VERSION:
|
||||
host_surfaces = metadata.get("host_surfaces")
|
||||
if not isinstance(host_surfaces, dict):
|
||||
host_surfaces = {}
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_HOST_SURFACES",
|
||||
"message": "Missing host_surfaces object",
|
||||
}
|
||||
)
|
||||
for surface_name, expected in DEFAULT_HOST_SURFACES.items():
|
||||
surface = host_surfaces.get(surface_name)
|
||||
if not isinstance(surface, dict):
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_HOST_SURFACE_MISSING",
|
||||
"message": f"Missing host_surfaces.{surface_name}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
for field_name in ("generation", "anchor_key", "hosted_version_mode"):
|
||||
if surface.get(field_name) != expected[field_name]:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_HOST_SURFACE_CONTRACT",
|
||||
"message": (
|
||||
f"host_surfaces.{surface_name}.{field_name} does not "
|
||||
"match the supported generation contract"
|
||||
),
|
||||
}
|
||||
)
|
||||
if surface_name == "desktop":
|
||||
for field_name in ("core_version", "frontend_version"):
|
||||
if surface.get(field_name) != expected[field_name]:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_HOST_SURFACE_CONTRACT",
|
||||
"message": (
|
||||
f"host_surfaces.desktop.{field_name} must match "
|
||||
"the fixed legacy bundle"
|
||||
),
|
||||
}
|
||||
)
|
||||
elif (
|
||||
surface.get("core_version") is not None
|
||||
or surface.get("frontend_version") is not None
|
||||
):
|
||||
violations.append(
|
||||
{
|
||||
"code": "R90_META_HOST_SURFACE_VERSION_MODE",
|
||||
"message": (
|
||||
"Managed-install Desktop hosted versions must remain "
|
||||
"installation-specific"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
age_days: Optional[int] = None
|
||||
if parsed_last is not None:
|
||||
@@ -256,11 +383,13 @@ def normalize_observed_anchors(
|
||||
comfyui: Optional[str] = None,
|
||||
comfyui_frontend: Optional[str] = None,
|
||||
desktop: Optional[str] = None,
|
||||
comfy_desktop: str | None = None,
|
||||
) -> Dict[str, str]:
|
||||
return {
|
||||
"comfyui": (comfyui or "").strip() or "unknown",
|
||||
"comfyui_frontend": (comfyui_frontend or "").strip() or "unknown",
|
||||
"desktop": (desktop or "").strip() or "unknown",
|
||||
"comfy_desktop": (comfy_desktop or "").strip() or "unknown",
|
||||
}
|
||||
|
||||
|
||||
@@ -295,16 +424,27 @@ def _compare_semver(left: Optional[str], right: Optional[str]) -> Optional[int]:
|
||||
|
||||
def build_host_surface_contract(
|
||||
published_anchors: Optional[Dict[str, Any]],
|
||||
*,
|
||||
published_surfaces: dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
anchors = dict(published_anchors or {})
|
||||
surface_contracts = copy.deepcopy(
|
||||
published_surfaces
|
||||
if isinstance(published_surfaces, dict)
|
||||
else DEFAULT_HOST_SURFACES
|
||||
)
|
||||
standalone_anchor = str(anchors.get("comfyui_frontend", "unknown"))
|
||||
desktop_anchor = str(anchors.get("desktop", "unknown"))
|
||||
comfy_desktop_anchor = str(anchors.get("comfy_desktop", "unknown"))
|
||||
standalone_frontend_version = _extract_semver(standalone_anchor)
|
||||
|
||||
desktop_match = DESKTOP_ANCHOR_RE.match(desktop_anchor)
|
||||
desktop_version = None
|
||||
desktop_core_version = None
|
||||
desktop_embedded_frontend_version = None
|
||||
comfy_desktop_version = None
|
||||
comfy_desktop_revision = None
|
||||
comfy_desktop_describe = None
|
||||
violations: List[Dict[str, str]] = []
|
||||
|
||||
if desktop_anchor != "unknown" and desktop_match is None:
|
||||
@@ -319,6 +459,73 @@ def build_host_surface_contract(
|
||||
desktop_core_version = desktop_match.group("core")
|
||||
desktop_embedded_frontend_version = desktop_match.group("frontend")
|
||||
|
||||
legacy_surface = surface_contracts.get("desktop")
|
||||
if not isinstance(legacy_surface, dict):
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_DESKTOP_SURFACE_MISSING",
|
||||
"message": "Legacy Desktop host-surface contract is missing",
|
||||
}
|
||||
)
|
||||
legacy_surface = {}
|
||||
elif legacy_surface != DEFAULT_HOST_SURFACES["desktop"]:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_DESKTOP_SURFACE_DESCRIPTOR",
|
||||
"message": "Legacy Desktop host-surface contract is invalid",
|
||||
}
|
||||
)
|
||||
comfy_desktop_surface = surface_contracts.get("comfy_desktop")
|
||||
if not isinstance(comfy_desktop_surface, dict):
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_COMFY_DESKTOP_SURFACE_MISSING",
|
||||
"message": "Current Desktop host-surface contract is missing",
|
||||
}
|
||||
)
|
||||
comfy_desktop_surface = {}
|
||||
if comfy_desktop_surface.get("anchor_key") != "comfy_desktop":
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_COMFY_DESKTOP_ANCHOR_KEY",
|
||||
"message": "Current Desktop must reference the comfy_desktop anchor",
|
||||
}
|
||||
)
|
||||
if comfy_desktop_surface.get("generation") != "managed_install":
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_COMFY_DESKTOP_GENERATION",
|
||||
"message": "Current Desktop must use the managed_install generation",
|
||||
}
|
||||
)
|
||||
if (
|
||||
comfy_desktop_surface.get("hosted_version_mode") != "installation_specific"
|
||||
or comfy_desktop_surface.get("core_version") is not None
|
||||
or comfy_desktop_surface.get("frontend_version") is not None
|
||||
):
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_COMFY_DESKTOP_HOSTED_VERSION_MODE",
|
||||
"message": (
|
||||
"Current Desktop hosted core/frontend versions are "
|
||||
"installation-specific and must not be fixed"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
comfy_desktop_match = COMFY_DESKTOP_ANCHOR_RE.match(comfy_desktop_anchor)
|
||||
if comfy_desktop_anchor != "unknown" and comfy_desktop_match is None:
|
||||
violations.append(
|
||||
{
|
||||
"code": "R164_COMFY_DESKTOP_ANCHOR_PARSE",
|
||||
"message": "Current Desktop anchor did not match the expected format",
|
||||
}
|
||||
)
|
||||
elif comfy_desktop_match is not None:
|
||||
comfy_desktop_version = comfy_desktop_match.group("desktop")
|
||||
comfy_desktop_revision = comfy_desktop_match.group("revision")
|
||||
comfy_desktop_describe = comfy_desktop_match.group("describe")
|
||||
|
||||
compare_result = _compare_semver(
|
||||
desktop_embedded_frontend_version, standalone_frontend_version
|
||||
)
|
||||
@@ -346,6 +553,8 @@ def build_host_surface_contract(
|
||||
"desktop": {
|
||||
"anchor": desktop_anchor,
|
||||
"desktop_version": desktop_version,
|
||||
"generation": legacy_surface.get("generation"),
|
||||
"hosted_version_mode": legacy_surface.get("hosted_version_mode"),
|
||||
"core_version": desktop_core_version,
|
||||
"embedded_frontend_version": desktop_embedded_frontend_version,
|
||||
"frontend_parity": {
|
||||
@@ -353,6 +562,17 @@ def build_host_surface_contract(
|
||||
"reference_frontend_version": standalone_frontend_version,
|
||||
},
|
||||
},
|
||||
"comfy_desktop": {
|
||||
"anchor": comfy_desktop_anchor,
|
||||
"application_version": comfy_desktop_version,
|
||||
"desktop_version": comfy_desktop_version,
|
||||
"source_revision": comfy_desktop_revision,
|
||||
"source_describe": comfy_desktop_describe,
|
||||
"generation": comfy_desktop_surface.get("generation"),
|
||||
"hosted_version_mode": comfy_desktop_surface.get("hosted_version_mode"),
|
||||
"core_version": comfy_desktop_surface.get("core_version"),
|
||||
"frontend_version": comfy_desktop_surface.get("frontend_version"),
|
||||
},
|
||||
},
|
||||
"violations": violations,
|
||||
}
|
||||
@@ -461,9 +681,11 @@ def run_refresh_workflow(
|
||||
publish_stage: Dict[str, Any] = {"mode": "dry-run", "updated": False}
|
||||
updated_text = doc["text"]
|
||||
metadata_after = copy.deepcopy(metadata)
|
||||
metadata_after["schema_version"] = CURRENT_SCHEMA_VERSION
|
||||
metadata_after.setdefault("policy", {})
|
||||
metadata_after.setdefault("anchors", {})
|
||||
metadata_after.setdefault("evidence", {})
|
||||
metadata_after["host_surfaces"] = copy.deepcopy(DEFAULT_HOST_SURFACES)
|
||||
metadata_after["last_validated_date"] = today.isoformat()
|
||||
for key in ANCHOR_KEYS:
|
||||
metadata_after["anchors"][key] = observed.get(key, "unknown")
|
||||
@@ -477,7 +699,8 @@ def run_refresh_workflow(
|
||||
drift_after = detect_anchor_drift(metadata_after.get("anchors"), observed)
|
||||
validate_stage["after"] = validate_after
|
||||
|
||||
if apply:
|
||||
# IMPORTANT: never publish unresolved or malformed host anchors.
|
||||
if apply and validate_after["ok"]:
|
||||
updated_text = replace_metadata_block(doc["text"], metadata_after)
|
||||
p.write_text(updated_text, encoding="utf-8")
|
||||
publish_stage = {
|
||||
@@ -489,6 +712,14 @@ def run_refresh_workflow(
|
||||
_body_without_meta(updated_text).encode("utf-8")
|
||||
).hexdigest(),
|
||||
}
|
||||
elif apply:
|
||||
publish_stage = {
|
||||
"mode": "apply",
|
||||
"updated": False,
|
||||
"blocked_by": validate_after["code"],
|
||||
"metadata_preview_hash": _json_hash(metadata_after),
|
||||
"drift_after": drift_after,
|
||||
}
|
||||
else:
|
||||
publish_stage = {
|
||||
"mode": "dry-run",
|
||||
@@ -502,8 +733,10 @@ def run_refresh_workflow(
|
||||
decision_codes.append(drift_before["code"])
|
||||
if bootstrap_mode:
|
||||
decision_codes.append("R90_BOOTSTRAP_METADATA")
|
||||
if apply:
|
||||
if apply and validate_after["ok"]:
|
||||
decision_codes.append("R90_PUBLISH_APPLY")
|
||||
elif apply:
|
||||
decision_codes.append("R90_PUBLISH_REJECTED")
|
||||
else:
|
||||
decision_codes.append("R90_PUBLISH_DRY_RUN")
|
||||
|
||||
|
||||
@@ -100,7 +100,9 @@ def _dedupe_keep_order(values: list[str]) -> list[str]:
|
||||
def evaluate_connector_allowlist_posture(
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
env: Mapping[str, str] = environ or os.environ
|
||||
# IMPORTANT: an explicit empty mapping is an empty posture fixture, not a request
|
||||
# to fall back to ambient process state.
|
||||
env: Mapping[str, str] = os.environ if environ is None else environ
|
||||
|
||||
active_platforms: list[str] = []
|
||||
unguarded_platforms: list[str] = []
|
||||
@@ -154,7 +156,7 @@ def evaluate_connector_allowlist_posture(
|
||||
def is_strict_connector_allowlist_profile(
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
) -> bool:
|
||||
env: Mapping[str, str] = environ or os.environ
|
||||
env: Mapping[str, str] = os.environ if environ is None else environ
|
||||
deployment_profile = (env.get("OPENCLAW_DEPLOYMENT_PROFILE") or "").strip().lower()
|
||||
runtime_profile = (env.get("OPENCLAW_RUNTIME_PROFILE") or "").strip().lower()
|
||||
return deployment_profile == "public" or runtime_profile == "hardened"
|
||||
|
||||
+100
-25
@@ -15,11 +15,16 @@ Enforcement rule:
|
||||
- profile=public + mode=EMBEDDED -> requires explicit override + warning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, FrozenSet, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Dict, FrozenSet, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .effective_security_posture import EffectiveSecurityPosture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +69,24 @@ HIGH_RISK_SURFACES: FrozenSet[Tuple[str, str]] = frozenset(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_control_plane_mode(deployment_profile: str = "") -> ControlPlaneMode:
|
||||
def _effective_posture(
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> EffectiveSecurityPosture | None:
|
||||
if posture is not None:
|
||||
return posture
|
||||
try:
|
||||
from .effective_security_posture import get_effective_security_posture
|
||||
|
||||
return get_effective_security_posture(required=False)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_control_plane_mode(
|
||||
deployment_profile: str = "",
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> ControlPlaneMode:
|
||||
"""
|
||||
Determine the active control-plane mode.
|
||||
|
||||
@@ -73,6 +95,10 @@ def resolve_control_plane_mode(deployment_profile: str = "") -> ControlPlaneMode
|
||||
2. profile=public defaults to SPLIT.
|
||||
3. Everything else defaults to EMBEDDED.
|
||||
"""
|
||||
effective = _effective_posture(posture)
|
||||
if effective is not None:
|
||||
return ControlPlaneMode(effective.control_plane_mode)
|
||||
|
||||
explicit = os.environ.get(ENV_CONTROL_PLANE_MODE, "").lower().strip()
|
||||
if explicit == "split":
|
||||
return ControlPlaneMode.SPLIT
|
||||
@@ -86,12 +112,20 @@ def resolve_control_plane_mode(deployment_profile: str = "") -> ControlPlaneMode
|
||||
return ControlPlaneMode.EMBEDDED
|
||||
|
||||
|
||||
def is_split_mode() -> bool:
|
||||
def is_split_mode(*, posture: EffectiveSecurityPosture | None = None) -> bool:
|
||||
"""Convenience check for split mode."""
|
||||
|
||||
from .deployment_profile import evaluate_deployment_profile
|
||||
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
return resolve_control_plane_mode(profile) == ControlPlaneMode.SPLIT
|
||||
effective = _effective_posture(posture)
|
||||
profile = (
|
||||
effective.deployment_profile
|
||||
if effective is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
)
|
||||
return (
|
||||
resolve_control_plane_mode(profile, posture=effective) == ControlPlaneMode.SPLIT
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -102,6 +136,8 @@ def is_split_mode() -> bool:
|
||||
def get_blocked_surfaces(
|
||||
deployment_profile: str,
|
||||
mode: Optional[ControlPlaneMode] = None,
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Return list of (surface_id, reason) blocked in current configuration.
|
||||
@@ -109,7 +145,11 @@ def get_blocked_surfaces(
|
||||
In public + split: all HIGH_RISK_SURFACES are blocked.
|
||||
In embedded or non-public: nothing blocked.
|
||||
"""
|
||||
if mode is None:
|
||||
effective = _effective_posture(posture)
|
||||
if effective is not None:
|
||||
deployment_profile = effective.deployment_profile
|
||||
mode = ControlPlaneMode(effective.control_plane_mode)
|
||||
elif mode is None:
|
||||
mode = resolve_control_plane_mode(deployment_profile)
|
||||
|
||||
if deployment_profile == "public" and mode == ControlPlaneMode.SPLIT:
|
||||
@@ -118,10 +158,19 @@ def get_blocked_surfaces(
|
||||
return []
|
||||
|
||||
|
||||
def is_surface_blocked(surface_id: str) -> bool:
|
||||
def is_surface_blocked(
|
||||
surface_id: str,
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> bool:
|
||||
"""Check if a specific surface is blocked in current config."""
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
blocked = get_blocked_surfaces(profile)
|
||||
effective = _effective_posture(posture)
|
||||
profile = (
|
||||
effective.deployment_profile
|
||||
if effective is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
)
|
||||
blocked = get_blocked_surfaces(profile, posture=effective)
|
||||
return any(sid == surface_id for sid, _ in blocked)
|
||||
|
||||
|
||||
@@ -146,7 +195,10 @@ class SplitPrereqReport:
|
||||
}
|
||||
|
||||
|
||||
def validate_split_prerequisites() -> SplitPrereqReport:
|
||||
def validate_split_prerequisites(
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> SplitPrereqReport:
|
||||
"""
|
||||
Validate that all prerequisites for split mode are met.
|
||||
|
||||
@@ -158,24 +210,38 @@ def validate_split_prerequisites() -> SplitPrereqReport:
|
||||
"""
|
||||
report = SplitPrereqReport()
|
||||
|
||||
url = os.environ.get(ENV_CONTROL_PLANE_URL, "").strip()
|
||||
token = os.environ.get(ENV_CONTROL_PLANE_TOKEN, "").strip()
|
||||
effective = _effective_posture(posture)
|
||||
url_configured = (
|
||||
effective.control_plane_url_configured
|
||||
if effective is not None
|
||||
else bool(os.environ.get(ENV_CONTROL_PLANE_URL, "").strip())
|
||||
)
|
||||
token_configured = (
|
||||
effective.control_plane_token_configured
|
||||
if effective is not None
|
||||
else bool(os.environ.get(ENV_CONTROL_PLANE_TOKEN, "").strip())
|
||||
)
|
||||
|
||||
if not url:
|
||||
if not url_configured:
|
||||
report.passed = False
|
||||
report.errors.append(
|
||||
f"S62: Split mode requires {ENV_CONTROL_PLANE_URL} but it is not set."
|
||||
)
|
||||
|
||||
if not token:
|
||||
if not token_configured:
|
||||
report.passed = False
|
||||
report.errors.append(
|
||||
f"S62: Split mode requires {ENV_CONTROL_PLANE_TOKEN} but it is not set."
|
||||
)
|
||||
|
||||
# Check for compat override (dev-only, auditable)
|
||||
compat = os.environ.get(ENV_SPLIT_COMPAT_OVERRIDE, "").lower().strip()
|
||||
if compat in ("1", "true", "yes"):
|
||||
compat_override = (
|
||||
effective.control_plane_compat_override
|
||||
if effective is not None
|
||||
else os.environ.get(ENV_SPLIT_COMPAT_OVERRIDE, "").lower().strip()
|
||||
in ("1", "true", "yes")
|
||||
)
|
||||
if compat_override:
|
||||
report.warnings.append(
|
||||
"S62: OPENCLAW_SPLIT_COMPAT_OVERRIDE is active. "
|
||||
"This bypasses split enforcement and is for dev-only use."
|
||||
@@ -184,7 +250,10 @@ def validate_split_prerequisites() -> SplitPrereqReport:
|
||||
return report
|
||||
|
||||
|
||||
def enforce_control_plane_startup() -> Dict:
|
||||
def enforce_control_plane_startup(
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
Run control-plane startup validation.
|
||||
|
||||
@@ -196,12 +265,18 @@ def enforce_control_plane_startup() -> Dict:
|
||||
|
||||
Returns diagnostic dict for startup report.
|
||||
"""
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
mode = resolve_control_plane_mode(profile)
|
||||
compat_override = os.environ.get(ENV_SPLIT_COMPAT_OVERRIDE, "").lower().strip() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
effective = _effective_posture(posture)
|
||||
profile = (
|
||||
effective.deployment_profile
|
||||
if effective is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
)
|
||||
mode = resolve_control_plane_mode(profile, posture=effective)
|
||||
compat_override = (
|
||||
effective.control_plane_compat_override
|
||||
if effective is not None
|
||||
else os.environ.get(ENV_SPLIT_COMPAT_OVERRIDE, "").lower().strip()
|
||||
in ("1", "true", "yes")
|
||||
)
|
||||
|
||||
result: Dict = {
|
||||
@@ -209,7 +284,7 @@ def enforce_control_plane_startup() -> Dict:
|
||||
"control_plane_mode": mode.value,
|
||||
"blocked_surfaces": [
|
||||
{"id": sid, "reason": desc}
|
||||
for sid, desc in get_blocked_surfaces(profile, mode)
|
||||
for sid, desc in get_blocked_surfaces(profile, mode, posture=effective)
|
||||
],
|
||||
"startup_passed": True,
|
||||
"errors": [],
|
||||
@@ -217,7 +292,7 @@ def enforce_control_plane_startup() -> Dict:
|
||||
}
|
||||
|
||||
if profile == "public" and mode == ControlPlaneMode.SPLIT:
|
||||
prereq = validate_split_prerequisites()
|
||||
prereq = validate_split_prerequisites(posture=effective)
|
||||
if not prereq.passed:
|
||||
result["startup_passed"] = False
|
||||
result["errors"] = prereq.errors
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Compatibility alias for the effective security posture implementation module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .posture import effective as _implementation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Static-only exports keep legacy imports typed without duplicating installed state.
|
||||
from .posture.effective import EffectiveSecurityPosture as EffectiveSecurityPosture
|
||||
from .posture.effective import PostureFinding as PostureFinding
|
||||
from .posture.effective import (
|
||||
effective_security_posture_diagnostics as effective_security_posture_diagnostics,
|
||||
)
|
||||
from .posture.effective import (
|
||||
get_effective_security_posture as get_effective_security_posture,
|
||||
)
|
||||
from .posture.effective import (
|
||||
get_or_create_effective_security_posture as get_or_create_effective_security_posture,
|
||||
)
|
||||
from .posture.effective import (
|
||||
install_effective_security_posture as install_effective_security_posture,
|
||||
)
|
||||
from .posture.effective import (
|
||||
reset_effective_security_posture_for_tests as reset_effective_security_posture_for_tests,
|
||||
)
|
||||
from .posture.effective import (
|
||||
resolve_effective_security_posture as resolve_effective_security_posture,
|
||||
)
|
||||
|
||||
# IMPORTANT: alias the module object; copied re-exports split installed posture state.
|
||||
sys.modules[__name__] = _implementation
|
||||
@@ -114,6 +114,7 @@ MODEL_TYPE_EXCLUSION_REASONS = {
|
||||
"diffusers": "diffusers is folder-valued and needs a directory-tree install design",
|
||||
"classifiers": "classifiers are extensionless and need a dedicated content policy",
|
||||
"custom_nodes": "custom_nodes are executable plugin code, not managed model files",
|
||||
"datasets": "datasets contain user-managed training data, not managed model weights",
|
||||
}
|
||||
MODEL_TYPE_ALIASES = {
|
||||
"config": "configs",
|
||||
|
||||
@@ -288,6 +288,7 @@ def check_compatibility_matrix_governance(
|
||||
- OPENCLAW_COMPAT_ANCHOR_COMFYUI
|
||||
- OPENCLAW_COMPAT_ANCHOR_COMFYUI_FRONTEND
|
||||
- OPENCLAW_COMPAT_ANCHOR_DESKTOP
|
||||
- OPENCLAW_COMPAT_ANCHOR_COMFY_DESKTOP
|
||||
"""
|
||||
matrix_path = pack_root / "docs" / "release" / "compatibility_matrix.md"
|
||||
if not matrix_path.exists():
|
||||
@@ -325,10 +326,12 @@ def check_compatibility_matrix_governance(
|
||||
comfyui=os.environ.get("OPENCLAW_COMPAT_ANCHOR_COMFYUI"),
|
||||
comfyui_frontend=os.environ.get("OPENCLAW_COMPAT_ANCHOR_COMFYUI_FRONTEND"),
|
||||
desktop=os.environ.get("OPENCLAW_COMPAT_ANCHOR_DESKTOP"),
|
||||
comfy_desktop=os.environ.get("OPENCLAW_COMPAT_ANCHOR_COMFY_DESKTOP"),
|
||||
)
|
||||
drift = detect_anchor_drift((doc.get("metadata") or {}).get("anchors"), observed)
|
||||
host_contract = build_host_surface_contract(
|
||||
(doc.get("metadata") or {}).get("anchors")
|
||||
(doc.get("metadata") or {}).get("anchors"),
|
||||
published_surfaces=(doc.get("metadata") or {}).get("host_surfaces"),
|
||||
)
|
||||
|
||||
report.environment["compat_matrix_validation_code"] = str(
|
||||
@@ -346,6 +349,14 @@ def check_compatibility_matrix_governance(
|
||||
.get("status", "")
|
||||
)
|
||||
)
|
||||
report.environment["compat_comfy_desktop_generation"] = str(
|
||||
host_contract.get("surfaces", {}).get("comfy_desktop", {}).get("generation", "")
|
||||
)
|
||||
report.environment["compat_comfy_desktop_hosted_version_mode"] = str(
|
||||
host_contract.get("surfaces", {})
|
||||
.get("comfy_desktop", {})
|
||||
.get("hosted_version_mode", "")
|
||||
)
|
||||
|
||||
if not validation.get("ok"):
|
||||
report.add(
|
||||
@@ -412,20 +423,22 @@ def check_compatibility_matrix_governance(
|
||||
host_contract.get("violations", []), ensure_ascii=False
|
||||
),
|
||||
remediation=(
|
||||
"Record the desktop bundle anchor in the expected "
|
||||
"`<desktop> (core <core> / frontend <frontend>)` format."
|
||||
"Record both Desktop generations: the legacy "
|
||||
"`<desktop> (core <core> / frontend <frontend>)` bundle and the "
|
||||
"current `<app> (<revision> / <describe>)` managed-install anchor."
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
desktop_surface = host_contract["surfaces"]["desktop"]
|
||||
current_desktop_surface = host_contract["surfaces"]["comfy_desktop"]
|
||||
parity = desktop_surface["frontend_parity"]
|
||||
report.add(
|
||||
CheckResult(
|
||||
name="compatibility_matrix_host_surface_contract",
|
||||
severity=Severity.PASS.value,
|
||||
message=(
|
||||
"Desktop host surface tracked separately "
|
||||
"Desktop host generations tracked separately "
|
||||
f"({parity['status']} vs standalone frontend)"
|
||||
),
|
||||
detail=json.dumps(
|
||||
@@ -438,6 +451,16 @@ def check_compatibility_matrix_governance(
|
||||
"reference_frontend_version": parity[
|
||||
"reference_frontend_version"
|
||||
],
|
||||
"comfy_desktop_anchor": current_desktop_surface["anchor"],
|
||||
"comfy_desktop_version": current_desktop_surface[
|
||||
"desktop_version"
|
||||
],
|
||||
"comfy_desktop_generation": current_desktop_surface[
|
||||
"generation"
|
||||
],
|
||||
"comfy_desktop_hosted_version_mode": current_desktop_surface[
|
||||
"hosted_version_mode"
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
|
||||
+123
-96
@@ -10,7 +10,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -29,6 +29,44 @@ else: # pragma: no cover (test-only import mode)
|
||||
check_rate_limit,
|
||||
)
|
||||
|
||||
if __package__ and "." in __package__:
|
||||
from ..services import parameter_lab_policy as _parameter_lab_policy
|
||||
from ..services.parameter_lab_policy import (
|
||||
ParameterLabValidationError,
|
||||
serialize_plan_payload,
|
||||
validate_compare_input,
|
||||
validate_sweep_dimensions,
|
||||
validate_workflow,
|
||||
)
|
||||
from ..services.safe_io import safe_write_text
|
||||
else: # pragma: no cover (test-only import mode)
|
||||
from services import parameter_lab_policy as _parameter_lab_policy
|
||||
from services.parameter_lab_policy import (
|
||||
ParameterLabValidationError,
|
||||
serialize_plan_payload,
|
||||
validate_compare_input,
|
||||
validate_sweep_dimensions,
|
||||
validate_workflow,
|
||||
)
|
||||
from services.safe_io import safe_write_text
|
||||
|
||||
PARAMETER_LAB_POLICY_VERSION = _parameter_lab_policy.PARAMETER_LAB_POLICY_VERSION
|
||||
PARAMETER_LAB_POLICY = _parameter_lab_policy.PARAMETER_LAB_POLICY
|
||||
MAX_PARAMETER_LAB_REQUEST_BYTES = _parameter_lab_policy.MAX_PARAMETER_LAB_REQUEST_BYTES
|
||||
MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES = (
|
||||
_parameter_lab_policy.MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES
|
||||
)
|
||||
MAX_SWEEP_DIMENSIONS = _parameter_lab_policy.MAX_SWEEP_DIMENSIONS
|
||||
MAX_VALUES_PER_DIMENSION = _parameter_lab_policy.MAX_VALUES_PER_DIMENSION
|
||||
MAX_NODE_ID_UTF8_BYTES = _parameter_lab_policy.MAX_NODE_ID_UTF8_BYTES
|
||||
MAX_WIDGET_NAME_UTF8_BYTES = _parameter_lab_policy.MAX_WIDGET_NAME_UTF8_BYTES
|
||||
MAX_SCALAR_STRING_UTF8_BYTES = _parameter_lab_policy.MAX_SCALAR_STRING_UTF8_BYTES
|
||||
MAX_PARAMETER_LAB_PLAN_UTF8_BYTES = (
|
||||
_parameter_lab_policy.MAX_PARAMETER_LAB_PLAN_UTF8_BYTES
|
||||
)
|
||||
MAX_SWEEP_COMBINATIONS = _parameter_lab_policy.MAX_SWEEP_COMBINATIONS
|
||||
MAX_COMPARE_ITEMS = _parameter_lab_policy.MAX_COMPARE_ITEMS
|
||||
|
||||
# R98: Endpoint Metadata
|
||||
if __package__ and "." in __package__:
|
||||
from ..services.endpoint_manifest import (
|
||||
@@ -48,8 +86,6 @@ else:
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.services.parameter_lab")
|
||||
|
||||
# Configuration
|
||||
MAX_SWEEP_COMBINATIONS = 50 # Hard cap to prevent queue flooding
|
||||
MAX_COMPARE_ITEMS = 8 # F50: Hard cap for side-by-side comparison
|
||||
EXPERIMENT_RETENTION_COUNT = 20
|
||||
|
||||
|
||||
@@ -79,48 +115,27 @@ class SweepPlan:
|
||||
class SweepPlanner:
|
||||
"""Generates bounded sweep plans."""
|
||||
|
||||
def generate(self, workflow: str, params: List[Dict[str, Any]]) -> SweepPlan:
|
||||
if not isinstance(workflow, str) or not workflow.strip():
|
||||
raise ValueError("workflow_json is required")
|
||||
if not isinstance(params, list):
|
||||
raise ValueError("params must be a list")
|
||||
|
||||
exp_id = f"exp_{uuid.uuid4().hex[:8]}"
|
||||
dimensions: List[SweepDimension] = []
|
||||
|
||||
for p in params:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
node_id = p.get("node_id")
|
||||
widget_name = p.get("widget_name")
|
||||
if node_id is None or not isinstance(widget_name, str) or not widget_name:
|
||||
continue
|
||||
|
||||
dim = SweepDimension(
|
||||
node_id=str(node_id),
|
||||
widget_name=widget_name,
|
||||
values=(
|
||||
p.get("values", []) if isinstance(p.get("values", []), list) else []
|
||||
),
|
||||
strategy=str(p.get("strategy", "grid")),
|
||||
count=int(p.get("count", 0) or 0),
|
||||
def generate(self, workflow: Any, params: List[Dict[str, Any]]) -> SweepPlan:
|
||||
normalized_workflow = validate_workflow(workflow)
|
||||
normalized_params = validate_sweep_dimensions(params)
|
||||
dimensions: List[SweepDimension] = [
|
||||
SweepDimension(
|
||||
node_id=dimension["node_id"],
|
||||
widget_name=dimension["widget_name"],
|
||||
values=dimension["values"],
|
||||
strategy=dimension["strategy"],
|
||||
count=dimension["count"],
|
||||
)
|
||||
dimensions.append(dim)
|
||||
|
||||
for dimension in normalized_params
|
||||
]
|
||||
overrides_list = self._generate_combinations(dimensions)
|
||||
# F52: Bounded Invariant Check
|
||||
count = len(overrides_list)
|
||||
if count > MAX_SWEEP_COMBINATIONS:
|
||||
raise ValueError(
|
||||
f"Sweep size {count} exceeds limit {MAX_SWEEP_COMBINATIONS}"
|
||||
)
|
||||
|
||||
return SweepPlan(
|
||||
experiment_id=exp_id,
|
||||
workflow_json=workflow,
|
||||
# IMPORTANT: validate a same-length placeholder before allocating any experiment ID.
|
||||
candidate = SweepPlan(
|
||||
experiment_id="exp_00000000",
|
||||
workflow_json=normalized_workflow,
|
||||
dimensions=dimensions,
|
||||
runs=overrides_list,
|
||||
# F52: Schema V1 Lock
|
||||
schema_version="1.0",
|
||||
combination_cap=MAX_SWEEP_COMBINATIONS,
|
||||
budget_cap=MAX_SWEEP_COMBINATIONS,
|
||||
@@ -130,6 +145,8 @@ class SweepPlanner:
|
||||
"lock_reason": "f52_closeout",
|
||||
},
|
||||
)
|
||||
serialize_plan_payload(asdict(candidate))
|
||||
return replace(candidate, experiment_id=f"exp_{uuid.uuid4().hex[:8]}")
|
||||
|
||||
def _generate_combinations(
|
||||
self, dimensions: List[SweepDimension]
|
||||
@@ -173,37 +190,18 @@ class ComparePlanner:
|
||||
"""
|
||||
|
||||
def generate(
|
||||
self, workflow: str, items: List[Any], node_id: Any, widget_name: str
|
||||
self, workflow: Any, items: List[Any], node_id: Any, widget_name: str
|
||||
) -> SweepPlan:
|
||||
if not isinstance(workflow, str) or not workflow.strip():
|
||||
raise ValueError("workflow_json is required")
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("items must be a non-empty list")
|
||||
if node_id is None:
|
||||
raise ValueError("node_id is required")
|
||||
if not isinstance(widget_name, str) or not widget_name.strip():
|
||||
raise ValueError("widget_name is required")
|
||||
if len(items) > MAX_COMPARE_ITEMS:
|
||||
raise ValueError(f"Too many items for comparison (max {MAX_COMPARE_ITEMS})")
|
||||
|
||||
normalized_items: List[Any] = []
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
if not item.strip():
|
||||
raise ValueError("items must not contain empty strings")
|
||||
normalized_items.append(item)
|
||||
continue
|
||||
if isinstance(item, (int, float, bool)):
|
||||
normalized_items.append(item)
|
||||
continue
|
||||
raise ValueError("items must contain only scalar values")
|
||||
|
||||
exp_id = f"cmp_{uuid.uuid4().hex[:8]}"
|
||||
normalized_workflow = validate_workflow(workflow)
|
||||
validated_compare = validate_compare_input(items, node_id, widget_name)
|
||||
normalized_items: List[Any] = validated_compare[0]
|
||||
normalized_node_id = validated_compare[1]
|
||||
normalized_widget_name = validated_compare[2]
|
||||
|
||||
# Create a single dimension for the model/item
|
||||
dim = SweepDimension(
|
||||
node_id=str(node_id),
|
||||
widget_name=widget_name,
|
||||
node_id=normalized_node_id,
|
||||
widget_name=normalized_widget_name,
|
||||
values=normalized_items,
|
||||
strategy="compare",
|
||||
)
|
||||
@@ -211,23 +209,24 @@ class ComparePlanner:
|
||||
# Generate runs (1 per item)
|
||||
runs = []
|
||||
for val in normalized_items:
|
||||
runs.append({f"{node_id}.{widget_name}": val})
|
||||
runs.append({f"{normalized_node_id}.{normalized_widget_name}": val})
|
||||
|
||||
return SweepPlan(
|
||||
experiment_id=exp_id,
|
||||
workflow_json=workflow,
|
||||
candidate = SweepPlan(
|
||||
experiment_id="cmp_00000000",
|
||||
workflow_json=normalized_workflow,
|
||||
dimensions=[dim],
|
||||
runs=runs,
|
||||
# F52: Schema V1 Lock
|
||||
schema_version="1.0",
|
||||
combination_cap=MAX_COMPARE_ITEMS,
|
||||
budget_cap=MAX_COMPARE_ITEMS, # F50: Budget aligns with compare limit
|
||||
budget_cap=MAX_COMPARE_ITEMS,
|
||||
replay_metadata={
|
||||
"replay_input_version": "1.0",
|
||||
"compat_state": "supported",
|
||||
"lock_reason": "f50_closeout",
|
||||
},
|
||||
)
|
||||
serialize_plan_payload(asdict(candidate))
|
||||
return replace(candidate, experiment_id=f"cmp_{uuid.uuid4().hex[:8]}")
|
||||
|
||||
|
||||
_compare_planner = ComparePlanner()
|
||||
@@ -264,9 +263,14 @@ class ExperimentStore:
|
||||
logger.warning("Retention check failed: %s", exc)
|
||||
|
||||
def save_plan(self, plan: SweepPlan) -> None:
|
||||
path = self.store_dir / f"{plan.experiment_id}.json"
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(asdict(plan), handle, indent=2)
|
||||
serialized = serialize_plan_payload(asdict(plan))
|
||||
# IMPORTANT: keep validation before file creation and retention mutation.
|
||||
safe_write_text(
|
||||
str(self.store_dir),
|
||||
f"{plan.experiment_id}.json",
|
||||
serialized,
|
||||
atomic=True,
|
||||
)
|
||||
self._enforce_retention()
|
||||
|
||||
def get_plan(self, exp_id: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -286,7 +290,7 @@ class ExperimentStore:
|
||||
"note": "Legacy experiment; full replay guarantees not active",
|
||||
}
|
||||
|
||||
return data
|
||||
return data # type: ignore[no-any-return]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -393,6 +397,36 @@ def _require_admin(request: web.Request) -> Optional[web.Response]:
|
||||
return None
|
||||
|
||||
|
||||
async def _read_creation_payload(request: web.Request) -> dict[str, Any]:
|
||||
content_length = request.content_length
|
||||
if content_length is not None and content_length > MAX_PARAMETER_LAB_REQUEST_BYTES:
|
||||
raise ParameterLabValidationError("payload_too_large", status=413)
|
||||
|
||||
raw_body = bytearray()
|
||||
while True:
|
||||
remaining = MAX_PARAMETER_LAB_REQUEST_BYTES + 1 - len(raw_body)
|
||||
if remaining <= 0:
|
||||
raise ParameterLabValidationError("payload_too_large", status=413)
|
||||
chunk = await request.content.read(min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
raw_body.extend(chunk)
|
||||
if len(raw_body) > MAX_PARAMETER_LAB_REQUEST_BYTES:
|
||||
raise ParameterLabValidationError("payload_too_large", status=413)
|
||||
|
||||
try:
|
||||
data = json.loads(raw_body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ParameterLabValidationError("invalid_json") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ParameterLabValidationError("invalid_payload")
|
||||
return data
|
||||
|
||||
|
||||
def _validation_response(exc: ParameterLabValidationError) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": exc.code}, status=exc.status)
|
||||
|
||||
|
||||
@endpoint_metadata(
|
||||
auth=AuthTier.ADMIN,
|
||||
risk=RiskTier.MEDIUM,
|
||||
@@ -410,13 +444,9 @@ async def create_compare_handler(request: web.Request) -> web.Response:
|
||||
return deny
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
# Input validation.
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"ok": False, "error": "invalid_payload"}, status=400)
|
||||
data = await _read_creation_payload(request)
|
||||
except ParameterLabValidationError as exc:
|
||||
return _validation_response(exc)
|
||||
|
||||
workflow = data.get("workflow_json")
|
||||
items = data.get("items", []) # List of comparison values.
|
||||
@@ -438,10 +468,10 @@ async def create_compare_handler(request: web.Request) -> web.Response:
|
||||
plan = _compare_planner.generate(workflow, items, node_id, widget_name)
|
||||
get_store().save_plan(plan)
|
||||
return web.json_response({"ok": True, "plan": asdict(plan)})
|
||||
except ValueError as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
except ParameterLabValidationError as exc:
|
||||
return _validation_response(exc)
|
||||
except Exception as exc:
|
||||
logger.error("Compare creation failed: %s", exc)
|
||||
logger.error("Compare creation failed (%s)", type(exc).__name__)
|
||||
return web.json_response({"ok": False, "error": "internal_error"}, status=500)
|
||||
|
||||
|
||||
@@ -462,12 +492,9 @@ async def create_sweep_handler(request: web.Request) -> web.Response:
|
||||
return deny
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"ok": False, "error": "invalid_payload"}, status=400)
|
||||
data = await _read_creation_payload(request)
|
||||
except ParameterLabValidationError as exc:
|
||||
return _validation_response(exc)
|
||||
|
||||
workflow = data.get("workflow_json")
|
||||
params = data.get("params", [])
|
||||
@@ -476,10 +503,10 @@ async def create_sweep_handler(request: web.Request) -> web.Response:
|
||||
plan = _planner.generate(workflow, params)
|
||||
get_store().save_plan(plan)
|
||||
return web.json_response({"ok": True, "plan": asdict(plan)})
|
||||
except ValueError as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
except ParameterLabValidationError as exc:
|
||||
return _validation_response(exc)
|
||||
except Exception as exc:
|
||||
logger.error("Sweep creation failed: %s", exc)
|
||||
logger.error("Sweep creation failed (%s)", type(exc).__name__)
|
||||
return web.json_response({"ok": False, "error": "internal_error"}, status=500)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Versioned, dependency-light validation policy for Parameter Lab creation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
PARAMETER_LAB_POLICY_VERSION = "1.0"
|
||||
MAX_PARAMETER_LAB_REQUEST_BYTES = 5 * 1024 * 1024
|
||||
MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES = 4 * 1024 * 1024
|
||||
MAX_SWEEP_DIMENSIONS = 8
|
||||
MAX_VALUES_PER_DIMENSION = 50
|
||||
MAX_NODE_ID_UTF8_BYTES = 128
|
||||
MAX_WIDGET_NAME_UTF8_BYTES = 256
|
||||
MAX_SCALAR_STRING_UTF8_BYTES = 16 * 1024
|
||||
MAX_PARAMETER_LAB_PLAN_UTF8_BYTES = 8 * 1024 * 1024
|
||||
MAX_SWEEP_COMBINATIONS = 50
|
||||
MAX_COMPARE_ITEMS = 8
|
||||
|
||||
PARAMETER_LAB_POLICY = MappingProxyType(
|
||||
{
|
||||
"version": PARAMETER_LAB_POLICY_VERSION,
|
||||
"max_request_bytes": MAX_PARAMETER_LAB_REQUEST_BYTES,
|
||||
"max_workflow_utf8_bytes": MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES,
|
||||
"max_sweep_dimensions": MAX_SWEEP_DIMENSIONS,
|
||||
"max_values_per_dimension": MAX_VALUES_PER_DIMENSION,
|
||||
"max_node_id_utf8_bytes": MAX_NODE_ID_UTF8_BYTES,
|
||||
"max_widget_name_utf8_bytes": MAX_WIDGET_NAME_UTF8_BYTES,
|
||||
"max_scalar_string_utf8_bytes": MAX_SCALAR_STRING_UTF8_BYTES,
|
||||
"max_plan_utf8_bytes": MAX_PARAMETER_LAB_PLAN_UTF8_BYTES,
|
||||
"max_sweep_combinations": MAX_SWEEP_COMBINATIONS,
|
||||
"max_compare_items": MAX_COMPARE_ITEMS,
|
||||
}
|
||||
)
|
||||
|
||||
_ERROR_MESSAGES = MappingProxyType(
|
||||
{
|
||||
"payload_too_large": "Parameter Lab request exceeds the byte limit",
|
||||
"invalid_json": "Request body must be valid JSON",
|
||||
"invalid_payload": "Request payload must be an object",
|
||||
"workflow_required": "workflow_json is required",
|
||||
"workflow_too_large": "workflow_json exceeds the byte limit",
|
||||
"params_must_be_list": "params must be a list",
|
||||
"items_must_be_list": "items must be a non-empty list",
|
||||
"dimensions_required": "At least one sweep dimension is required",
|
||||
"too_many_dimensions": "Too many sweep dimensions",
|
||||
"invalid_dimension": "Each sweep dimension must be an object",
|
||||
"node_id_required": "node_id is required",
|
||||
"invalid_node_id": "node_id is not a supported identifier",
|
||||
"node_id_too_large": "node_id exceeds the byte limit",
|
||||
"widget_name_required": "widget_name is required",
|
||||
"invalid_widget_name": "widget_name is not a supported identifier",
|
||||
"widget_name_too_large": "widget_name exceeds the byte limit",
|
||||
"values_required": "values must be a non-empty list",
|
||||
"too_many_values": (
|
||||
f"Values per dimension exceeds limit {MAX_VALUES_PER_DIMENSION}"
|
||||
),
|
||||
"invalid_scalar_value": "items must contain only scalar values",
|
||||
"scalar_string_too_large": "A scalar string exceeds the byte limit",
|
||||
"duplicate_ambiguous_value": "Values contain a presentation-ambiguous duplicate",
|
||||
"duplicate_dimension": "Duplicate node/widget dimension",
|
||||
"invalid_strategy": "Only grid sweep strategy is supported",
|
||||
"sweep_too_large": (f"Sweep size exceeds limit {MAX_SWEEP_COMBINATIONS}"),
|
||||
"plan_too_large": "Serialized Parameter Lab plan exceeds the byte limit",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ParameterLabValidationError(ValueError):
|
||||
"""Content-free creation validation error with a stable public reason code."""
|
||||
|
||||
def __init__(self, code: str, *, status: int = 400, message: str | None = None):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(
|
||||
message or _ERROR_MESSAGES.get(code, "Invalid Parameter Lab request")
|
||||
)
|
||||
|
||||
|
||||
def utf8_size(value: str) -> int:
|
||||
return len(value.encode("utf-8"))
|
||||
|
||||
|
||||
def _contains_control(value: str) -> bool:
|
||||
return any(ord(char) < 32 or ord(char) == 127 for char in value)
|
||||
|
||||
|
||||
def validate_workflow(workflow: Any) -> str:
|
||||
if not isinstance(workflow, str) or not workflow.strip():
|
||||
raise ParameterLabValidationError("workflow_required")
|
||||
if utf8_size(workflow) > MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES:
|
||||
raise ParameterLabValidationError("workflow_too_large", status=413)
|
||||
return workflow
|
||||
|
||||
|
||||
def normalize_node_id(node_id: Any) -> str:
|
||||
if node_id is None:
|
||||
raise ParameterLabValidationError("node_id_required")
|
||||
if isinstance(node_id, bool):
|
||||
raise ParameterLabValidationError("invalid_node_id")
|
||||
if isinstance(node_id, int):
|
||||
normalized = str(node_id)
|
||||
elif isinstance(node_id, str):
|
||||
normalized = node_id
|
||||
else:
|
||||
raise ParameterLabValidationError("invalid_node_id")
|
||||
if not normalized.strip():
|
||||
raise ParameterLabValidationError("node_id_required")
|
||||
# IMPORTANT: run override keys use the first "." as the node/widget separator.
|
||||
if "." in normalized or _contains_control(normalized):
|
||||
raise ParameterLabValidationError("invalid_node_id")
|
||||
if utf8_size(normalized) > MAX_NODE_ID_UTF8_BYTES:
|
||||
raise ParameterLabValidationError("node_id_too_large")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_widget_name(widget_name: Any) -> str:
|
||||
if not isinstance(widget_name, str) or not widget_name.strip():
|
||||
raise ParameterLabValidationError("widget_name_required")
|
||||
if _contains_control(widget_name):
|
||||
raise ParameterLabValidationError("invalid_widget_name")
|
||||
if utf8_size(widget_name) > MAX_WIDGET_NAME_UTF8_BYTES:
|
||||
raise ParameterLabValidationError("widget_name_too_large")
|
||||
return widget_name
|
||||
|
||||
|
||||
def _normalize_scalar(value: Any, *, allow_empty_string: bool) -> Any:
|
||||
if isinstance(value, str):
|
||||
if not allow_empty_string and not value.strip():
|
||||
raise ParameterLabValidationError("invalid_scalar_value")
|
||||
if utf8_size(value) > MAX_SCALAR_STRING_UTF8_BYTES:
|
||||
raise ParameterLabValidationError("scalar_string_too_large")
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float) and math.isfinite(value):
|
||||
return value
|
||||
raise ParameterLabValidationError("invalid_scalar_value")
|
||||
|
||||
|
||||
def _presentation_key(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if value == 0:
|
||||
return "0"
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return json.dumps(value, allow_nan=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def validate_scalar_values(
|
||||
values: Any,
|
||||
*,
|
||||
max_values: int = MAX_VALUES_PER_DIMENSION,
|
||||
allow_empty_string: bool = True,
|
||||
) -> list[Any]:
|
||||
if not isinstance(values, list) or not values:
|
||||
raise ParameterLabValidationError("values_required")
|
||||
if len(values) > max_values:
|
||||
raise ParameterLabValidationError("too_many_values")
|
||||
normalized: list[Any] = []
|
||||
seen_presentations = set()
|
||||
for value in values:
|
||||
scalar = _normalize_scalar(value, allow_empty_string=allow_empty_string)
|
||||
presentation = _presentation_key(scalar)
|
||||
if presentation in seen_presentations:
|
||||
raise ParameterLabValidationError("duplicate_ambiguous_value")
|
||||
seen_presentations.add(presentation)
|
||||
normalized.append(scalar)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_sweep_dimensions(params: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(params, list):
|
||||
raise ParameterLabValidationError("params_must_be_list")
|
||||
if not params:
|
||||
raise ParameterLabValidationError("dimensions_required")
|
||||
if len(params) > MAX_SWEEP_DIMENSIONS:
|
||||
raise ParameterLabValidationError("too_many_dimensions")
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen_dimensions = set()
|
||||
combinations = 1
|
||||
for raw_dimension in params:
|
||||
if not isinstance(raw_dimension, dict):
|
||||
raise ParameterLabValidationError("invalid_dimension")
|
||||
node_id = normalize_node_id(raw_dimension.get("node_id"))
|
||||
widget_name = normalize_widget_name(raw_dimension.get("widget_name"))
|
||||
dimension_key = (node_id, widget_name)
|
||||
if dimension_key in seen_dimensions:
|
||||
raise ParameterLabValidationError("duplicate_dimension")
|
||||
seen_dimensions.add(dimension_key)
|
||||
|
||||
strategy = raw_dimension.get("strategy", "grid")
|
||||
if strategy != "grid":
|
||||
raise ParameterLabValidationError("invalid_strategy")
|
||||
values = validate_scalar_values(raw_dimension.get("values"))
|
||||
combinations *= len(values)
|
||||
if combinations > MAX_SWEEP_COMBINATIONS:
|
||||
raise ParameterLabValidationError("sweep_too_large")
|
||||
normalized.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"widget_name": widget_name,
|
||||
"values": values,
|
||||
"strategy": "grid",
|
||||
"count": 0,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_compare_input(
|
||||
items: Any, node_id: Any, widget_name: Any
|
||||
) -> tuple[list[Any], str, str]:
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ParameterLabValidationError("items_must_be_list")
|
||||
if len(items) > MAX_COMPARE_ITEMS:
|
||||
raise ParameterLabValidationError(
|
||||
"too_many_values",
|
||||
message=f"Too many items for comparison (max {MAX_COMPARE_ITEMS})",
|
||||
)
|
||||
normalized_node_id = normalize_node_id(node_id)
|
||||
normalized_widget_name = normalize_widget_name(widget_name)
|
||||
normalized_items = validate_scalar_values(
|
||||
items,
|
||||
max_values=MAX_COMPARE_ITEMS,
|
||||
allow_empty_string=False,
|
||||
)
|
||||
return normalized_items, normalized_node_id, normalized_widget_name
|
||||
|
||||
|
||||
def serialize_plan_payload(payload: Any) -> str:
|
||||
try:
|
||||
serialized = json.dumps(
|
||||
payload,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ParameterLabValidationError("invalid_scalar_value") from exc
|
||||
if utf8_size(serialized) > MAX_PARAMETER_LAB_PLAN_UTF8_BYTES:
|
||||
raise ParameterLabValidationError("plan_too_large", status=413)
|
||||
return serialized
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Authoritative, transient prompt-ID carrier for Parameter Lab submissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
PARAMETER_LAB_RECEIPT_KEY = "__openclaw_parameter_lab_receipt__"
|
||||
PARAMETER_LAB_RECEIPT_VERSION = 1
|
||||
|
||||
_CANONICAL_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-" r"[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
|
||||
|
||||
def _valid_marker(marker: Any) -> str | None:
|
||||
if not isinstance(marker, dict):
|
||||
return None
|
||||
if set(marker) != {"version", "prompt_id"}:
|
||||
return None
|
||||
if marker.get("version") != PARAMETER_LAB_RECEIPT_VERSION:
|
||||
return None
|
||||
prompt_id = marker.get("prompt_id")
|
||||
if not isinstance(prompt_id, str) or not _CANONICAL_UUID_RE.fullmatch(prompt_id):
|
||||
return None
|
||||
return prompt_id
|
||||
|
||||
|
||||
def consume_parameter_lab_queue_receipt(json_data: Any) -> Any:
|
||||
"""Strip one transient marker and promote its UUID to native ``prompt_id``.
|
||||
|
||||
The function is copy-on-write so other prompt handlers never observe an in-place
|
||||
mutation of their input. Invalid markers are still stripped, but never gain
|
||||
identifier authority.
|
||||
"""
|
||||
|
||||
if not isinstance(json_data, dict):
|
||||
return json_data
|
||||
extra_data = json_data.get("extra_data")
|
||||
if not isinstance(extra_data, dict):
|
||||
return json_data
|
||||
extra_pnginfo = extra_data.get("extra_pnginfo")
|
||||
if not isinstance(extra_pnginfo, dict):
|
||||
return json_data
|
||||
workflow = extra_pnginfo.get("workflow")
|
||||
if not isinstance(workflow, dict):
|
||||
return json_data
|
||||
workflow_extra = workflow.get("extra")
|
||||
if (
|
||||
not isinstance(workflow_extra, dict)
|
||||
or PARAMETER_LAB_RECEIPT_KEY not in workflow_extra
|
||||
):
|
||||
return json_data
|
||||
|
||||
marker = workflow_extra.get(PARAMETER_LAB_RECEIPT_KEY)
|
||||
prompt_id = _valid_marker(marker)
|
||||
|
||||
# CRITICAL: the carrier is transient; never retain it in queue/history/image metadata.
|
||||
next_workflow_extra = dict(workflow_extra)
|
||||
next_workflow_extra.pop(PARAMETER_LAB_RECEIPT_KEY, None)
|
||||
next_workflow = dict(workflow)
|
||||
next_workflow["extra"] = next_workflow_extra
|
||||
next_pnginfo = dict(extra_pnginfo)
|
||||
next_pnginfo["workflow"] = next_workflow
|
||||
next_extra_data = dict(extra_data)
|
||||
next_extra_data["extra_pnginfo"] = next_pnginfo
|
||||
result = dict(json_data)
|
||||
result["extra_data"] = next_extra_data
|
||||
|
||||
if prompt_id is not None:
|
||||
# CRITICAL: the frontend assigns this exact UUID only after promptQueued.
|
||||
# Preserving a different earlier handler value would cross-assign lifecycle
|
||||
# events to the wrong Parameter Lab run.
|
||||
result["prompt_id"] = prompt_id
|
||||
return result
|
||||
|
||||
|
||||
def register_parameter_lab_queue_receipt_handler(server: Any) -> bool:
|
||||
"""Register the official ComfyUI on-prompt handler exactly once."""
|
||||
|
||||
handlers = getattr(server, "on_prompt_handlers", ())
|
||||
if isinstance(handlers, (list, tuple)) and (
|
||||
consume_parameter_lab_queue_receipt in handlers
|
||||
):
|
||||
return False
|
||||
add_handler = getattr(server, "add_on_prompt_handler", None)
|
||||
if not callable(add_handler):
|
||||
raise RuntimeError("ComfyUI host does not expose add_on_prompt_handler")
|
||||
add_handler(consume_parameter_lab_queue_receipt)
|
||||
return True
|
||||
@@ -0,0 +1,4 @@
|
||||
# ruff: noqa: N999
|
||||
"""Process-static security posture implementation package."""
|
||||
|
||||
__all__ = ["effective"]
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Immutable process-static security posture implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSY = frozenset({"0", "false", "no", "off"})
|
||||
_CONTROL_PLANE_TRUTHY = frozenset({"1", "true", "yes"})
|
||||
_VALID_DEPLOYMENT_PROFILES = frozenset({"local", "lan", "public"})
|
||||
_VALID_WEBHOOK_MODES = frozenset({"bearer", "hmac", "bearer_or_hmac"})
|
||||
_installed_posture: EffectiveSecurityPosture | None = None
|
||||
_posture_lock = threading.RLock()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PostureFinding:
|
||||
severity: str
|
||||
code: str
|
||||
message: str
|
||||
remediation: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class EffectiveSecurityPosture:
|
||||
schema_version: int
|
||||
runtime_profile: str
|
||||
deployment_profile: str
|
||||
mae_profile: str
|
||||
network_exposed: bool
|
||||
admin_token_configured: bool
|
||||
observability_token_configured: bool
|
||||
dangerous_profile_override: bool
|
||||
dangerous_bind_override: bool
|
||||
localhost_no_origin_override: bool
|
||||
allow_any_public_llm_host: bool
|
||||
allow_insecure_base_url: bool
|
||||
webhook_auth_mode: str
|
||||
webhook_bearer_configured: bool
|
||||
webhook_hmac_configured: bool
|
||||
webhook_replay_protection_required: bool
|
||||
remote_admin_enabled: bool
|
||||
trust_x_forwarded_for: bool
|
||||
trusted_proxies_configured: bool
|
||||
callback_allow_hosts_configured: bool
|
||||
external_tools_enabled: bool
|
||||
registry_sync_enabled: bool
|
||||
transforms_enabled: bool
|
||||
bridge_enabled: bool
|
||||
bridge_device_token_configured: bool
|
||||
bridge_mtls_enabled: bool
|
||||
bridge_device_cert_map_configured: bool
|
||||
bridge_allowed_device_ids_configured: bool
|
||||
public_shared_surface_acknowledged: bool
|
||||
control_plane_mode: str
|
||||
control_plane_url_configured: bool
|
||||
control_plane_token_configured: bool
|
||||
control_plane_prerequisites_satisfied: bool
|
||||
control_plane_compat_override: bool
|
||||
connector_active_platforms: tuple[str, ...]
|
||||
connector_unguarded_platforms: tuple[str, ...]
|
||||
connector_recommended_allowlist_vars: tuple[str, ...]
|
||||
deployment_checks: tuple[PostureFinding, ...]
|
||||
deployment_pass_codes: tuple[str, ...]
|
||||
deployment_warn_codes: tuple[str, ...]
|
||||
deployment_fail_codes: tuple[str, ...]
|
||||
startup_profile_passed: bool
|
||||
startup_profile_overridden: bool
|
||||
startup_profile_violation_codes: tuple[str, ...]
|
||||
blocked_surface_ids: tuple[str, ...]
|
||||
decision_codes: tuple[str, ...]
|
||||
reason_codes: tuple[str, ...]
|
||||
|
||||
|
||||
def _read(
|
||||
environ: Mapping[str, str],
|
||||
primary: str,
|
||||
legacy: str | None = None,
|
||||
default: str = "",
|
||||
) -> str:
|
||||
try:
|
||||
if primary in environ:
|
||||
value = environ.get(primary, default)
|
||||
elif legacy and legacy in environ:
|
||||
value = environ.get(legacy, default)
|
||||
else:
|
||||
value = default
|
||||
except Exception:
|
||||
# CRITICAL: malformed environment providers must fail closed without echoing
|
||||
# exception content or the attempted value into diagnostics.
|
||||
raise ValueError("security posture input unavailable") from None
|
||||
if value is None:
|
||||
return ""
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
raise ValueError("security posture input is not scalar") from None
|
||||
|
||||
|
||||
def _normalized(
|
||||
environ: Mapping[str, str],
|
||||
primary: str,
|
||||
legacy: str | None = None,
|
||||
default: str = "",
|
||||
) -> str:
|
||||
return _read(environ, primary, legacy, default).strip().lower()
|
||||
|
||||
|
||||
def _enabled(
|
||||
environ: Mapping[str, str],
|
||||
primary: str,
|
||||
legacy: str | None = None,
|
||||
) -> bool:
|
||||
return _normalized(environ, primary, legacy) in _TRUTHY
|
||||
|
||||
|
||||
def _configured(
|
||||
environ: Mapping[str, str],
|
||||
primary: str,
|
||||
legacy: str | None = None,
|
||||
) -> bool:
|
||||
return bool(_read(environ, primary, legacy).strip())
|
||||
|
||||
|
||||
def _network_exposed_from_argv() -> bool:
|
||||
# Preserve the accepted S41 heuristic exactly: only the explicit --listen flag
|
||||
# changes this process-static decision.
|
||||
return "--listen" in sys.argv
|
||||
|
||||
|
||||
def _deployment_report(profile: str, environ: Mapping[str, str]):
|
||||
try:
|
||||
from ..deployment_profile import evaluate_deployment_profile
|
||||
except ImportError: # pragma: no cover - top-level compatibility mode
|
||||
from services.deployment_profile import evaluate_deployment_profile
|
||||
|
||||
return evaluate_deployment_profile(profile, environ)
|
||||
|
||||
|
||||
def _connector_posture(environ: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
try:
|
||||
from ..connector_allowlist_posture import evaluate_connector_allowlist_posture
|
||||
except ImportError: # pragma: no cover - top-level compatibility mode
|
||||
from services.connector_allowlist_posture import (
|
||||
evaluate_connector_allowlist_posture,
|
||||
)
|
||||
|
||||
return evaluate_connector_allowlist_posture(environ)
|
||||
|
||||
|
||||
def _blocked_surface_ids(profile: str, mode: str) -> tuple[str, ...]:
|
||||
if profile != "public" or mode != "split":
|
||||
return ()
|
||||
# IMPORTANT: these are the stable scalar IDs from the S62 registry. Importing
|
||||
# control_plane here would create a dependency cycle before R233 packages the domain.
|
||||
return (
|
||||
"callback_egress",
|
||||
"registry_sync",
|
||||
"secrets_write",
|
||||
"tool_execution",
|
||||
"transforms_exec",
|
||||
"webhook_execute",
|
||||
)
|
||||
|
||||
|
||||
def _safe_finding(check: Any) -> PostureFinding:
|
||||
message = str(check.message)
|
||||
if str(check.code) == "DP-WEBHOOK-005":
|
||||
# IMPORTANT: the legacy evaluator includes the raw invalid environment value.
|
||||
# The immutable boundary retains the stable code but never the untrusted value.
|
||||
message = "Unsupported webhook auth mode."
|
||||
return PostureFinding(
|
||||
severity=str(check.severity),
|
||||
code=str(check.code),
|
||||
message=message,
|
||||
remediation=str(check.remediation),
|
||||
)
|
||||
|
||||
|
||||
def resolve_effective_security_posture(
|
||||
environ: Mapping[str, str] | None = None,
|
||||
*,
|
||||
network_exposed: bool | None = None,
|
||||
) -> EffectiveSecurityPosture:
|
||||
# IMPORTANT: an explicitly supplied empty mapping means empty input. Do not use
|
||||
# `environ or os.environ`; doing so makes tests and lifecycle injection ambient.
|
||||
env = os.environ if environ is None else environ
|
||||
|
||||
resolved_network_exposed = (
|
||||
_network_exposed_from_argv()
|
||||
if network_exposed is None
|
||||
else bool(network_exposed)
|
||||
)
|
||||
deployment_profile = _normalized(
|
||||
env, "OPENCLAW_DEPLOYMENT_PROFILE", default="local"
|
||||
)
|
||||
if deployment_profile not in _VALID_DEPLOYMENT_PROFILES:
|
||||
raise ValueError("unsupported deployment profile")
|
||||
|
||||
raw_runtime_profile = _normalized(
|
||||
env, "OPENCLAW_RUNTIME_PROFILE", default="minimal"
|
||||
)
|
||||
runtime_profile = "hardened" if raw_runtime_profile == "hardened" else "minimal"
|
||||
mae_profile = (
|
||||
"hardened"
|
||||
if runtime_profile == "hardened" and deployment_profile != "public"
|
||||
else deployment_profile
|
||||
)
|
||||
|
||||
try:
|
||||
report = _deployment_report(deployment_profile, env)
|
||||
except Exception:
|
||||
# CRITICAL: delegated evaluators must not expose hostile mapping values or
|
||||
# exception text across the immutable posture boundary.
|
||||
raise ValueError("security posture evaluation failed") from None
|
||||
findings = tuple(_safe_finding(check) for check in report.checks)
|
||||
pass_codes = tuple(item.code for item in findings if item.severity == "pass")
|
||||
warn_codes = tuple(item.code for item in findings if item.severity == "warn")
|
||||
fail_codes = tuple(item.code for item in findings if item.severity == "fail")
|
||||
|
||||
dangerous_profile_override = _enabled(
|
||||
env, "OPENCLAW_SECURITY_DANGEROUS_PROFILE_OVERRIDE"
|
||||
)
|
||||
startup_violations = () if deployment_profile == "local" else fail_codes
|
||||
startup_overridden = bool(startup_violations and dangerous_profile_override)
|
||||
startup_passed = (
|
||||
deployment_profile == "local" or not startup_violations or startup_overridden
|
||||
)
|
||||
|
||||
explicit_control_mode = _normalized(env, "OPENCLAW_CONTROL_PLANE_MODE")
|
||||
if explicit_control_mode in {"embedded", "split"}:
|
||||
control_plane_mode = explicit_control_mode
|
||||
elif deployment_profile == "public":
|
||||
control_plane_mode = "split"
|
||||
else:
|
||||
control_plane_mode = "embedded"
|
||||
|
||||
control_plane_url_configured = _configured(env, "OPENCLAW_CONTROL_PLANE_URL")
|
||||
control_plane_token_configured = _configured(env, "OPENCLAW_CONTROL_PLANE_TOKEN")
|
||||
control_plane_prerequisites_satisfied = (
|
||||
control_plane_url_configured and control_plane_token_configured
|
||||
)
|
||||
control_plane_compat_override = (
|
||||
_normalized(env, "OPENCLAW_SPLIT_COMPAT_OVERRIDE") in _CONTROL_PLANE_TRUTHY
|
||||
)
|
||||
|
||||
try:
|
||||
connector = _connector_posture(env)
|
||||
except Exception:
|
||||
raise ValueError("security posture evaluation failed") from None
|
||||
active_platforms = tuple(
|
||||
sorted({str(item) for item in connector["active_platforms"]})
|
||||
)
|
||||
unguarded_platforms = tuple(
|
||||
sorted({str(item) for item in connector["unguarded_platforms"]})
|
||||
)
|
||||
recommended_allowlist_vars = tuple(
|
||||
sorted({str(item) for item in connector["recommended_allowlist_vars"]})
|
||||
)
|
||||
|
||||
reason_codes = list(startup_violations)
|
||||
if deployment_profile == "public" and control_plane_mode == "split":
|
||||
if not control_plane_url_configured:
|
||||
reason_codes.append("CP-URL-MISSING")
|
||||
if not control_plane_token_configured:
|
||||
reason_codes.append("CP-TOKEN-MISSING")
|
||||
elif deployment_profile == "public" and control_plane_mode == "embedded":
|
||||
if not control_plane_compat_override:
|
||||
reason_codes.append("CP-PUBLIC-EMBEDDED")
|
||||
reason_codes.extend(
|
||||
f"CONNECTOR-ALLOWLIST-{platform.upper()}" for platform in unguarded_platforms
|
||||
)
|
||||
if raw_runtime_profile not in {"", "minimal", "hardened"}:
|
||||
reason_codes.append("RUNTIME-PROFILE-DEFAULTED")
|
||||
|
||||
decision_codes = [
|
||||
(
|
||||
"STARTUP-OVERRIDDEN"
|
||||
if startup_overridden
|
||||
else "STARTUP-PASS" if startup_passed else "STARTUP-DENY"
|
||||
),
|
||||
(
|
||||
"CONTROL-PLANE-PASS"
|
||||
if (
|
||||
deployment_profile != "public"
|
||||
or (
|
||||
control_plane_mode == "split"
|
||||
and control_plane_prerequisites_satisfied
|
||||
)
|
||||
or (control_plane_mode == "embedded" and control_plane_compat_override)
|
||||
)
|
||||
else "CONTROL-PLANE-DENY"
|
||||
),
|
||||
(
|
||||
"CONNECTORS-NONE"
|
||||
if not active_platforms
|
||||
else "CONNECTORS-UNGUARDED" if unguarded_platforms else "CONNECTORS-GUARDED"
|
||||
),
|
||||
"NETWORK-EXPOSED" if resolved_network_exposed else "NETWORK-LOOPBACK",
|
||||
]
|
||||
|
||||
raw_webhook_mode = _normalized(
|
||||
env,
|
||||
"OPENCLAW_WEBHOOK_AUTH_MODE",
|
||||
"MOLTBOT_WEBHOOK_AUTH_MODE",
|
||||
)
|
||||
webhook_auth_mode = (
|
||||
raw_webhook_mode
|
||||
if raw_webhook_mode in _VALID_WEBHOOK_MODES
|
||||
else "unset" if not raw_webhook_mode else "invalid"
|
||||
)
|
||||
replay_value = _normalized(
|
||||
env,
|
||||
"OPENCLAW_WEBHOOK_REQUIRE_REPLAY_PROTECTION",
|
||||
"MOLTBOT_WEBHOOK_REQUIRE_REPLAY_PROTECTION",
|
||||
)
|
||||
|
||||
return EffectiveSecurityPosture(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
runtime_profile=runtime_profile,
|
||||
deployment_profile=deployment_profile,
|
||||
mae_profile=mae_profile,
|
||||
network_exposed=resolved_network_exposed,
|
||||
admin_token_configured=_configured(
|
||||
env, "OPENCLAW_ADMIN_TOKEN", "MOLTBOT_ADMIN_TOKEN"
|
||||
),
|
||||
observability_token_configured=_configured(
|
||||
env, "OPENCLAW_OBSERVABILITY_TOKEN", "MOLTBOT_OBSERVABILITY_TOKEN"
|
||||
),
|
||||
dangerous_profile_override=dangerous_profile_override,
|
||||
dangerous_bind_override=_enabled(
|
||||
env,
|
||||
"OPENCLAW_SECURITY_DANGEROUS_BIND_OVERRIDE",
|
||||
"MOLTBOT_SECURITY_DANGEROUS_BIND_OVERRIDE",
|
||||
),
|
||||
localhost_no_origin_override=(
|
||||
_normalized(env, "OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN") == "true"
|
||||
),
|
||||
allow_any_public_llm_host=_enabled(
|
||||
env,
|
||||
"OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST",
|
||||
"MOLTBOT_ALLOW_ANY_PUBLIC_LLM_HOST",
|
||||
),
|
||||
allow_insecure_base_url=_enabled(
|
||||
env,
|
||||
"OPENCLAW_ALLOW_INSECURE_BASE_URL",
|
||||
"MOLTBOT_ALLOW_INSECURE_BASE_URL",
|
||||
),
|
||||
webhook_auth_mode=webhook_auth_mode,
|
||||
webhook_bearer_configured=_configured(
|
||||
env,
|
||||
"OPENCLAW_WEBHOOK_BEARER_TOKEN",
|
||||
"MOLTBOT_WEBHOOK_BEARER_TOKEN",
|
||||
),
|
||||
webhook_hmac_configured=_configured(
|
||||
env,
|
||||
"OPENCLAW_WEBHOOK_HMAC_SECRET",
|
||||
"MOLTBOT_WEBHOOK_HMAC_SECRET",
|
||||
),
|
||||
webhook_replay_protection_required=replay_value not in _FALSY,
|
||||
remote_admin_enabled=_enabled(
|
||||
env, "OPENCLAW_ALLOW_REMOTE_ADMIN", "MOLTBOT_ALLOW_REMOTE_ADMIN"
|
||||
),
|
||||
trust_x_forwarded_for=_enabled(
|
||||
env,
|
||||
"OPENCLAW_TRUST_X_FORWARDED_FOR",
|
||||
"MOLTBOT_TRUST_X_FORWARDED_FOR",
|
||||
),
|
||||
trusted_proxies_configured=_configured(
|
||||
env, "OPENCLAW_TRUSTED_PROXIES", "MOLTBOT_TRUSTED_PROXIES"
|
||||
),
|
||||
callback_allow_hosts_configured=_configured(
|
||||
env,
|
||||
"OPENCLAW_CALLBACK_ALLOW_HOSTS",
|
||||
"MOLTBOT_CALLBACK_ALLOW_HOSTS",
|
||||
),
|
||||
external_tools_enabled=_enabled(env, "OPENCLAW_ENABLE_EXTERNAL_TOOLS"),
|
||||
registry_sync_enabled=_enabled(env, "OPENCLAW_ENABLE_REGISTRY_SYNC"),
|
||||
transforms_enabled=_enabled(env, "OPENCLAW_ENABLE_TRANSFORMS"),
|
||||
bridge_enabled=_enabled(
|
||||
env, "OPENCLAW_BRIDGE_ENABLED", "MOLTBOT_BRIDGE_ENABLED"
|
||||
),
|
||||
bridge_device_token_configured=_configured(
|
||||
env,
|
||||
"OPENCLAW_BRIDGE_DEVICE_TOKEN",
|
||||
"MOLTBOT_BRIDGE_DEVICE_TOKEN",
|
||||
),
|
||||
bridge_mtls_enabled=_enabled(env, "OPENCLAW_BRIDGE_MTLS_ENABLED"),
|
||||
bridge_device_cert_map_configured=_configured(
|
||||
env, "OPENCLAW_BRIDGE_DEVICE_CERT_MAP"
|
||||
),
|
||||
bridge_allowed_device_ids_configured=_configured(
|
||||
env,
|
||||
"OPENCLAW_BRIDGE_ALLOWED_DEVICE_IDS",
|
||||
"MOLTBOT_BRIDGE_ALLOWED_DEVICE_IDS",
|
||||
),
|
||||
public_shared_surface_acknowledged=_enabled(
|
||||
env,
|
||||
"OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK",
|
||||
"MOLTBOT_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK",
|
||||
),
|
||||
control_plane_mode=control_plane_mode,
|
||||
control_plane_url_configured=control_plane_url_configured,
|
||||
control_plane_token_configured=control_plane_token_configured,
|
||||
control_plane_prerequisites_satisfied=control_plane_prerequisites_satisfied,
|
||||
control_plane_compat_override=control_plane_compat_override,
|
||||
connector_active_platforms=active_platforms,
|
||||
connector_unguarded_platforms=unguarded_platforms,
|
||||
connector_recommended_allowlist_vars=recommended_allowlist_vars,
|
||||
deployment_checks=findings,
|
||||
deployment_pass_codes=pass_codes,
|
||||
deployment_warn_codes=warn_codes,
|
||||
deployment_fail_codes=fail_codes,
|
||||
startup_profile_passed=startup_passed,
|
||||
startup_profile_overridden=startup_overridden,
|
||||
startup_profile_violation_codes=startup_violations,
|
||||
blocked_surface_ids=_blocked_surface_ids(
|
||||
deployment_profile, control_plane_mode
|
||||
),
|
||||
decision_codes=tuple(decision_codes),
|
||||
reason_codes=tuple(dict.fromkeys(reason_codes)),
|
||||
)
|
||||
|
||||
|
||||
def install_effective_security_posture(
|
||||
posture: EffectiveSecurityPosture,
|
||||
) -> EffectiveSecurityPosture:
|
||||
if not isinstance(posture, EffectiveSecurityPosture):
|
||||
raise TypeError("posture must be EffectiveSecurityPosture")
|
||||
global _installed_posture
|
||||
with _posture_lock:
|
||||
if _installed_posture is None:
|
||||
_installed_posture = posture
|
||||
elif _installed_posture is not posture:
|
||||
# CRITICAL: silently replacing process posture creates contradictory
|
||||
# authorization decisions. Reset is an explicit lifecycle/test operation.
|
||||
raise RuntimeError("effective security posture is already installed")
|
||||
return _installed_posture
|
||||
|
||||
|
||||
def get_effective_security_posture(
|
||||
*, required: bool = True
|
||||
) -> EffectiveSecurityPosture | None:
|
||||
with _posture_lock:
|
||||
posture = _installed_posture
|
||||
if posture is None and required:
|
||||
raise RuntimeError("effective security posture is not installed")
|
||||
return posture
|
||||
|
||||
|
||||
def get_or_create_effective_security_posture(
|
||||
environ: Mapping[str, str] | None = None,
|
||||
*,
|
||||
network_exposed: bool | None = None,
|
||||
) -> EffectiveSecurityPosture:
|
||||
with _posture_lock:
|
||||
if _installed_posture is not None:
|
||||
return _installed_posture
|
||||
posture = resolve_effective_security_posture(
|
||||
environ,
|
||||
network_exposed=network_exposed,
|
||||
)
|
||||
# The RLock makes this identity-stable even under concurrent startup.
|
||||
return install_effective_security_posture(posture)
|
||||
|
||||
|
||||
def reset_effective_security_posture_for_tests() -> None:
|
||||
global _installed_posture
|
||||
with _posture_lock:
|
||||
_installed_posture = None
|
||||
|
||||
|
||||
def effective_security_posture_diagnostics(
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved = posture or get_effective_security_posture()
|
||||
assert resolved is not None
|
||||
return {
|
||||
"schema_version": resolved.schema_version,
|
||||
"runtime_profile": resolved.runtime_profile,
|
||||
"deployment_profile": resolved.deployment_profile,
|
||||
"mae_profile": resolved.mae_profile,
|
||||
"network_exposed": resolved.network_exposed,
|
||||
"authentication": {
|
||||
"admin_configured": resolved.admin_token_configured,
|
||||
"observability_configured": resolved.observability_token_configured,
|
||||
},
|
||||
"startup_gate": {
|
||||
"passed": resolved.startup_profile_passed,
|
||||
"overridden": resolved.startup_profile_overridden,
|
||||
"violation_codes": list(resolved.startup_profile_violation_codes),
|
||||
},
|
||||
"control_plane": {
|
||||
"mode": resolved.control_plane_mode,
|
||||
"prerequisites_satisfied": (resolved.control_plane_prerequisites_satisfied),
|
||||
"compat_override": resolved.control_plane_compat_override,
|
||||
"blocked_surface_count": len(resolved.blocked_surface_ids),
|
||||
},
|
||||
"connectors": {
|
||||
"active_count": len(resolved.connector_active_platforms),
|
||||
"unguarded_count": len(resolved.connector_unguarded_platforms),
|
||||
},
|
||||
"decision_codes": list(resolved.decision_codes),
|
||||
"reason_codes": list(resolved.reason_codes),
|
||||
}
|
||||
@@ -48,7 +48,9 @@ _LEGACY_INVENTORY_CACHE_KEY = "inventory"
|
||||
_INVENTORY_LOCK = threading.RLock()
|
||||
_INVENTORY_SCAN_THREAD: threading.Thread | None = None
|
||||
_INVENTORY_ERROR_RETRY_SEC = 5
|
||||
_INVENTORY_EXCLUDED_MODEL_TYPES = {"custom_nodes"}
|
||||
# CRITICAL: datasets are user training data and executable custom_nodes are code; neither
|
||||
# may cross the model inventory filename boundary.
|
||||
_INVENTORY_EXCLUDED_MODEL_TYPES = {"custom_nodes", "datasets"}
|
||||
|
||||
# Heuristic mapping: input_key -> folder_paths type
|
||||
_INPUT_KEY_MAP = {
|
||||
@@ -147,7 +149,11 @@ def _scan_model_inventory(checkpoint: List[str] | None = None) -> Dict[str, List
|
||||
|
||||
|
||||
def _copy_inventory_snapshot(models: Dict[str, List[str]]) -> Dict[str, List[str]]:
|
||||
return {key: list(value) for key, value in (models or {}).items()}
|
||||
return {
|
||||
key: list(value)
|
||||
for key, value in (models or {}).items()
|
||||
if key not in _INVENTORY_EXCLUDED_MODEL_TYPES
|
||||
}
|
||||
|
||||
|
||||
def _inventory_snapshot_stale_locked(now: float | None = None) -> bool:
|
||||
|
||||
+10
-335
@@ -1,343 +1,18 @@
|
||||
"""
|
||||
R130 route/bootstrap orchestration extracted from package entrypoint.
|
||||
|
||||
Keeps __init__.py thin while preserving startup behavior and fallback handling.
|
||||
"""
|
||||
"""Compatibility alias for the bootstrap registration implementation module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
_routes_registered = False
|
||||
from .bootstrap import registration as _implementation
|
||||
|
||||
|
||||
def _resolve_optional_warmup_timeout_sec() -> float:
|
||||
raw = (
|
||||
os.environ.get("OPENCLAW_STARTUP_WARMUP_TIMEOUT_SEC")
|
||||
or os.environ.get("MOLTBOT_STARTUP_WARMUP_TIMEOUT_SEC")
|
||||
or "5"
|
||||
if TYPE_CHECKING:
|
||||
# Static-only exports keep legacy imports typed without duplicating module globals.
|
||||
from .bootstrap.registration import register_routes_once as register_routes_once
|
||||
from .bootstrap.registration import (
|
||||
reset_route_bootstrap_for_tests as reset_route_bootstrap_for_tests,
|
||||
)
|
||||
try:
|
||||
return max(0.1, min(float(raw), 60.0))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
|
||||
def _warm_model_inventory_snapshot() -> None:
|
||||
from .preflight import get_model_inventory_snapshot
|
||||
|
||||
get_model_inventory_snapshot(trigger_refresh=True)
|
||||
|
||||
|
||||
def _build_optional_startup_warmups():
|
||||
timeout_sec = _resolve_optional_warmup_timeout_sec()
|
||||
return [
|
||||
("model_inventory", _warm_model_inventory_snapshot, timeout_sec),
|
||||
]
|
||||
|
||||
|
||||
def _mark_startup_ready_and_start_warmups() -> None:
|
||||
try:
|
||||
from .startup_lifecycle import mark_startup_ready, start_optional_warmups
|
||||
|
||||
mark_startup_ready("routes")
|
||||
start_optional_warmups(_build_optional_startup_warmups())
|
||||
except Exception:
|
||||
# IMPORTANT: optional warmup diagnostics must not undo successful route startup.
|
||||
logging.getLogger("ComfyUI-OpenClaw").exception(
|
||||
"R188: failed to start optional startup warmups"
|
||||
)
|
||||
|
||||
|
||||
def _mark_startup_fatal(phase: str, exc: BaseException) -> None:
|
||||
try:
|
||||
from .startup_lifecycle import mark_startup_fatal
|
||||
|
||||
mark_startup_fatal(phase, exc)
|
||||
except Exception:
|
||||
# IMPORTANT: preserve the original bootstrap exception even if diagnostics fail.
|
||||
logging.getLogger("ComfyUI-OpenClaw").exception(
|
||||
"R188: failed to record fatal startup state"
|
||||
)
|
||||
|
||||
|
||||
def _register_plugins_and_shutdown_hooks() -> None:
|
||||
# R67: Best-effort process shutdown hook for scheduler/failover flush.
|
||||
try:
|
||||
from .plugins.builtin import register_all
|
||||
from .runtime_lifecycle import register_shutdown_hooks
|
||||
|
||||
register_shutdown_hooks()
|
||||
register_all()
|
||||
except Exception as e:
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(f"Failed to register plugins: {e}")
|
||||
|
||||
|
||||
def _initialize_registries_and_security_gate() -> None:
|
||||
# R63/R84: Initialize Service & Module Registries.
|
||||
try:
|
||||
from .modules import ModuleCapability, ModuleRegistry, enable_module
|
||||
from .registry import SVC_RUNTIME_CONFIG, ServiceRegistry
|
||||
from .runtime_config import get_config
|
||||
|
||||
config = get_config()
|
||||
ServiceRegistry.register(SVC_RUNTIME_CONFIG, config)
|
||||
|
||||
# Always-on modules
|
||||
enable_module(ModuleCapability.CORE)
|
||||
enable_module(ModuleCapability.SECURITY)
|
||||
enable_module(ModuleCapability.OBSERVABILITY)
|
||||
|
||||
# S50: initialize durable idempotency storage early.
|
||||
from .idempotency_store import IdempotencyStore
|
||||
from .state_dir import get_state_dir
|
||||
|
||||
db_path = os.path.join(get_state_dir(), "idempotency.db")
|
||||
# CRITICAL: pass db_path as keyword (first positional arg is backend object).
|
||||
IdempotencyStore().configure_durable(db_path=db_path, strict_mode=True)
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"IdempotencyStore durable backend configured at: %s (strict_mode=True)",
|
||||
db_path,
|
||||
)
|
||||
|
||||
if config.bridge_enabled:
|
||||
enable_module(ModuleCapability.BRIDGE)
|
||||
|
||||
# Core runtime modules stay enabled; runners decide active behavior.
|
||||
enable_module(ModuleCapability.SCHEDULER)
|
||||
enable_module(ModuleCapability.WEBHOOK)
|
||||
enable_module(ModuleCapability.CONNECTOR)
|
||||
|
||||
ModuleRegistry.lock()
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"Initialized modules: %s", ModuleRegistry.get_enabled_list()
|
||||
)
|
||||
|
||||
from .security_gate import enforce_startup_gate
|
||||
|
||||
enforce_startup_gate()
|
||||
except Exception as e:
|
||||
logging.getLogger("ComfyUI-OpenClaw").error(
|
||||
f"Failed to initialize registries: {e}"
|
||||
)
|
||||
# CRITICAL: keep bootstrap fail-closed; swallowing startup gate errors
|
||||
# silently degrades security posture and can expose partial registration.
|
||||
raise
|
||||
|
||||
|
||||
def _do_full_registration(server) -> None:
|
||||
"""Register all OpenClaw routes including bridge/scheduler bindings."""
|
||||
from .access_control import require_admin_token
|
||||
from .plugins.async_bridge import run_async_in_sync_context
|
||||
from .queue_submit import submit_prompt
|
||||
from .route_bootstrap_contract import load_route_bootstrap_contract
|
||||
from .scheduler.runner import get_scheduler_runner, start_scheduler
|
||||
from .templates import get_template_service
|
||||
|
||||
contract = load_route_bootstrap_contract(__package__)
|
||||
register_approval_routes = contract["register_approval_routes"]
|
||||
BridgeHandlers = contract["BridgeHandlers"]
|
||||
register_preset_routes = contract["register_preset_routes"]
|
||||
register_routes = contract["register_routes"]
|
||||
register_schedule_routes = contract["register_schedule_routes"]
|
||||
register_trigger_routes = contract["register_trigger_routes"]
|
||||
|
||||
register_routes(server)
|
||||
register_preset_routes(server.app)
|
||||
register_schedule_routes(server.app, require_admin_token_fn=require_admin_token)
|
||||
|
||||
class QueueSubmitService:
|
||||
def submit(self, job_req):
|
||||
tmpl_svc = get_template_service()
|
||||
workflow = tmpl_svc.render_template(job_req.template_id, job_req.inputs)
|
||||
|
||||
async def _do_submit():
|
||||
return await submit_prompt(
|
||||
workflow,
|
||||
client_id=job_req.session_id or "bridge",
|
||||
extra_data={
|
||||
"openclaw": {"trace_id": job_req.trace_id},
|
||||
# Legacy key kept for existing tooling that expects this blob.
|
||||
"moltbot": {"trace_id": job_req.trace_id},
|
||||
},
|
||||
source="bridge",
|
||||
trace_id=job_req.trace_id,
|
||||
)
|
||||
|
||||
return run_async_in_sync_context(_do_submit())
|
||||
|
||||
bridge_handlers = BridgeHandlers(submit_service=QueueSubmitService())
|
||||
_register_bridge_routes(server.app.router, bridge_handlers)
|
||||
|
||||
async def unified_submit_fn(
|
||||
template_id,
|
||||
inputs,
|
||||
trace_id,
|
||||
idempotency_key,
|
||||
delivery=None,
|
||||
source="unknown",
|
||||
):
|
||||
"""Submit function for scheduler and trigger-triggered runs."""
|
||||
# NOTE: Use IdempotencyStore API (check_and_record/update_prompt_id).
|
||||
# Avoid legacy get_store/get/set usage; wrong API here breaks route registration at runtime.
|
||||
from .idempotency_store import IdempotencyStore
|
||||
from .queue_submit import submit_prompt as _submit_prompt
|
||||
from .templates import get_template_service as _get_template_service
|
||||
|
||||
store = IdempotencyStore()
|
||||
is_dup, existing_prompt_id = store.check_and_record(idempotency_key)
|
||||
if is_dup:
|
||||
return {"prompt_id": existing_prompt_id, "deduped": True}
|
||||
|
||||
tmpl_svc = _get_template_service()
|
||||
workflow = tmpl_svc.render_template(template_id, inputs)
|
||||
|
||||
result = await _submit_prompt(
|
||||
workflow,
|
||||
extra_data={
|
||||
"openclaw": {"trace_id": trace_id, "source": "automation"},
|
||||
"moltbot": {"trace_id": trace_id, "source": "automation"},
|
||||
},
|
||||
source=source,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
if result.get("prompt_id"):
|
||||
store.update_prompt_id(idempotency_key, result["prompt_id"])
|
||||
return result
|
||||
|
||||
runner = get_scheduler_runner()
|
||||
runner._submit_fn = unified_submit_fn
|
||||
start_scheduler()
|
||||
|
||||
register_trigger_routes(
|
||||
server.app,
|
||||
require_admin_token_fn=require_admin_token,
|
||||
submit_fn=unified_submit_fn,
|
||||
)
|
||||
register_approval_routes(
|
||||
server.app,
|
||||
require_admin_token_fn=require_admin_token,
|
||||
submit_fn=unified_submit_fn,
|
||||
)
|
||||
_mark_startup_ready_and_start_warmups()
|
||||
|
||||
|
||||
_BRIDGE_ROUTE_SPECS = (
|
||||
("add_post", "/moltbot/bridge/submit", "submit_handler"),
|
||||
("add_post", "/moltbot/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/moltbot/bridge/health", "health_handler"),
|
||||
("add_post", "/openclaw/bridge/submit", "submit_handler"),
|
||||
("add_post", "/openclaw/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/openclaw/bridge/health", "health_handler"),
|
||||
("add_post", "/api/moltbot/bridge/submit", "submit_handler"),
|
||||
("add_post", "/api/moltbot/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/api/moltbot/bridge/health", "health_handler"),
|
||||
("add_post", "/api/openclaw/bridge/submit", "submit_handler"),
|
||||
("add_post", "/api/openclaw/bridge/deliver", "deliver_handler"),
|
||||
("add_get", "/api/openclaw/bridge/health", "health_handler"),
|
||||
)
|
||||
|
||||
|
||||
def _register_bridge_routes(router, bridge_handlers) -> None:
|
||||
# IMPORTANT: keep bridge route registration table-driven.
|
||||
# Missing one alias path here silently breaks one control-plane surface while
|
||||
# leaving the rest apparently healthy, which is hard to diagnose during startup.
|
||||
for method_name, path, handler_name in _BRIDGE_ROUTE_SPECS:
|
||||
registrar = getattr(router, method_name, None)
|
||||
if registrar is None:
|
||||
continue
|
||||
try:
|
||||
registrar(path, getattr(bridge_handlers, handler_name))
|
||||
except RuntimeError:
|
||||
if path.startswith("/api/"):
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _start_registration_retry_loop() -> None:
|
||||
"""R25: Retry route registration while PromptServer is warming up."""
|
||||
|
||||
def _retry_worker():
|
||||
global _routes_registered
|
||||
attempts = 0
|
||||
max_attempts = 10
|
||||
delay = 2.0
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw")
|
||||
|
||||
while not _routes_registered and attempts < max_attempts:
|
||||
try:
|
||||
ps_mod = sys.modules.get("server")
|
||||
PromptServer = getattr(ps_mod, "PromptServer", None) if ps_mod else None
|
||||
if PromptServer and getattr(PromptServer, "instance", None) is not None:
|
||||
_do_full_registration(PromptServer.instance)
|
||||
_routes_registered = True
|
||||
logger.info(
|
||||
"Routes registered successfully on attempt %s", attempts + 1
|
||||
)
|
||||
return
|
||||
logger.debug(
|
||||
"PromptServer.instance not ready (attempt %s)", attempts + 1
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error registering routes (attempt %s)", attempts + 1)
|
||||
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, 30)
|
||||
attempts += 1
|
||||
|
||||
if not _routes_registered:
|
||||
_mark_startup_fatal(
|
||||
"route_registration_retry",
|
||||
RuntimeError(
|
||||
f"Failed to register routes after {max_attempts} attempts"
|
||||
),
|
||||
)
|
||||
logger.error(
|
||||
"Failed to register routes after %s attempts. API endpoints unavailable.",
|
||||
max_attempts,
|
||||
)
|
||||
|
||||
t = threading.Thread(target=_retry_worker, name="openclaw-route-retry", daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
def register_routes_once() -> None:
|
||||
global _routes_registered
|
||||
if _routes_registered:
|
||||
return
|
||||
|
||||
try:
|
||||
_register_plugins_and_shutdown_hooks()
|
||||
_initialize_registries_and_security_gate()
|
||||
except Exception as exc:
|
||||
_mark_startup_fatal("required_startup", exc)
|
||||
raise
|
||||
|
||||
try:
|
||||
ps_mod = sys.modules.get("server")
|
||||
PromptServer = getattr(ps_mod, "PromptServer", None) if ps_mod else None
|
||||
|
||||
if PromptServer and getattr(PromptServer, "instance", None) is not None:
|
||||
_do_full_registration(PromptServer.instance)
|
||||
_routes_registered = True
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"Routes registered successfully on initial attempt."
|
||||
)
|
||||
else:
|
||||
logging.getLogger("ComfyUI-OpenClaw").info(
|
||||
"PromptServer not ready, starting background registration retry loop..."
|
||||
)
|
||||
_start_registration_retry_loop()
|
||||
except Exception:
|
||||
_exc_type, exc, _tb = sys.exc_info()
|
||||
if exc is not None:
|
||||
_mark_startup_fatal("route_registration", exc)
|
||||
logging.getLogger("ComfyUI-OpenClaw").exception("Route registration failed")
|
||||
# CRITICAL: initial registration failures must fail closed. The retry loop is
|
||||
# only for PromptServer warm-up, not for hiding broken route/bootstrap state.
|
||||
raise
|
||||
# IMPORTANT: alias the module object; copied re-exports break accepted patch seams.
|
||||
sys.modules[__name__] = _implementation
|
||||
|
||||
@@ -81,7 +81,12 @@ def _validate_symbol(spec: BootstrapSymbolSpec, value: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
def load_route_bootstrap_contract(package_name: str | None) -> dict[str, Any]:
|
||||
def load_route_bootstrap_contract(
|
||||
package_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# IMPORTANT: default to this owner module. A nested registration caller's package
|
||||
# resolves `..api` as `services.api` and breaks ComfyUI route registration.
|
||||
package_name = __package__ if package_name is None else package_name
|
||||
contract: dict[str, Any] = {}
|
||||
for spec in ROUTE_BOOTSTRAP_SPECS:
|
||||
(value,) = import_attrs_dual(
|
||||
|
||||
@@ -39,13 +39,21 @@ def resolve_scheduler_execution_mode(config: Optional[dict] = None) -> str:
|
||||
if explicit in {SCHEDULER_EXECUTION_EMBEDDED, SCHEDULER_EXECUTION_DELEGATED}:
|
||||
return explicit
|
||||
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local").strip().lower()
|
||||
try:
|
||||
from ..control_plane import ControlPlaneMode, resolve_control_plane_mode
|
||||
from ..effective_security_posture import get_effective_security_posture
|
||||
|
||||
posture = get_effective_security_posture(required=False)
|
||||
profile = (
|
||||
posture.deployment_profile
|
||||
if posture is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local").strip().lower()
|
||||
)
|
||||
|
||||
if (
|
||||
profile == "public"
|
||||
and resolve_control_plane_mode(profile) == ControlPlaneMode.SPLIT
|
||||
and resolve_control_plane_mode(profile, posture=posture)
|
||||
== ControlPlaneMode.SPLIT
|
||||
):
|
||||
return SCHEDULER_EXECUTION_DELEGATED
|
||||
except Exception:
|
||||
|
||||
@@ -336,15 +336,22 @@ def is_secret_write_blocked() -> bool:
|
||||
"""
|
||||
try:
|
||||
from .control_plane import is_split_mode
|
||||
from .effective_security_posture import get_effective_security_posture
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
if not is_split_mode():
|
||||
posture = get_effective_security_posture(required=False)
|
||||
if not is_split_mode(posture=posture):
|
||||
return False
|
||||
|
||||
# Check override
|
||||
compat = os.environ.get(ENV_SPLIT_COMPAT_OVERRIDE, "").lower().strip()
|
||||
if compat in ("1", "true", "yes"):
|
||||
compat_override = (
|
||||
posture.control_plane_compat_override
|
||||
if posture is not None
|
||||
else os.environ.get(ENV_SPLIT_COMPAT_OVERRIDE, "").lower().strip()
|
||||
in ("1", "true", "yes")
|
||||
)
|
||||
if compat_override:
|
||||
logger.warning("S57: Secret write override active in split mode (DEV ONLY)")
|
||||
return False
|
||||
|
||||
|
||||
+105
-32
@@ -5,12 +5,17 @@ Enforces mandatory security controls when running in HARDENED profile.
|
||||
Fails startup if critical controls are missing or misconfigured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from .runtime_profile import get_runtime_profile, is_hardened_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .effective_security_posture import EffectiveSecurityPosture
|
||||
|
||||
try:
|
||||
from .connector_allowlist_posture import evaluate_connector_allowlist_posture
|
||||
except Exception:
|
||||
@@ -50,13 +55,25 @@ class SecurityGate:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def verify_mandatory_controls() -> Tuple[bool, List[str], List[str]]:
|
||||
def verify_mandatory_controls(
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> Tuple[bool, List[str], List[str]]:
|
||||
"""
|
||||
Check if all mandatory controls for the current profile are active.
|
||||
Returns: (passed: bool, warnings: List[str], fatal_errors: List[str])
|
||||
"""
|
||||
warnings = []
|
||||
fatal_errors = []
|
||||
hardened = (
|
||||
posture.runtime_profile == "hardened"
|
||||
if posture is not None
|
||||
else is_hardened_mode()
|
||||
)
|
||||
runtime_profile_value = (
|
||||
posture.runtime_profile
|
||||
if posture is not None
|
||||
else get_runtime_profile().value
|
||||
)
|
||||
|
||||
def _emit_startup_audit(action: str, outcome: str, details: dict) -> None:
|
||||
try:
|
||||
@@ -78,8 +95,12 @@ class SecurityGate:
|
||||
# OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN is an explicit operator override for
|
||||
# localhost tooling; surfacing it early avoids silent CSRF-boundary drift.
|
||||
allow_no_origin = (
|
||||
os.environ.get("OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN", "").strip().lower()
|
||||
== "true"
|
||||
posture.localhost_no_origin_override
|
||||
if posture is not None
|
||||
else (
|
||||
os.environ.get("OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN", "").strip().lower()
|
||||
== "true"
|
||||
)
|
||||
)
|
||||
if allow_no_origin:
|
||||
logger.warning(
|
||||
@@ -92,7 +113,7 @@ class SecurityGate:
|
||||
details={
|
||||
"env": "OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN",
|
||||
"value": "true",
|
||||
"profile": get_runtime_profile().value,
|
||||
"profile": runtime_profile_value,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,18 +121,35 @@ class SecurityGate:
|
||||
try:
|
||||
from .access_control import is_any_token_configured, is_auth_configured
|
||||
|
||||
is_exposed = SecurityGate._check_network_exposure()
|
||||
is_exposed = (
|
||||
posture.network_exposed
|
||||
if posture is not None
|
||||
else SecurityGate._check_network_exposure()
|
||||
)
|
||||
# S45 Policy: If exposed, ANY token is sufficient to say "we are not wide open".
|
||||
# (Though Admin token is preferred for full protection, basic auth presence satisfies "not accidentally open")
|
||||
auth_ready = is_any_token_configured()
|
||||
auth_ready = (
|
||||
(
|
||||
posture.admin_token_configured
|
||||
or posture.observability_token_configured
|
||||
)
|
||||
if posture is not None
|
||||
else is_any_token_configured()
|
||||
)
|
||||
|
||||
if is_exposed and not auth_ready:
|
||||
# Check for explicit override
|
||||
from .runtime_config import get_config
|
||||
dangerous_bind_override = (
|
||||
posture.dangerous_bind_override if posture is not None else None
|
||||
)
|
||||
if dangerous_bind_override is None:
|
||||
from .runtime_config import get_config
|
||||
|
||||
config = get_config()
|
||||
dangerous_bind_override = (
|
||||
get_config().security_dangerous_bind_override
|
||||
)
|
||||
|
||||
if config.security_dangerous_bind_override:
|
||||
if dangerous_bind_override:
|
||||
warnings.append(
|
||||
"WARNING: Server is exposed (--listen) without Authentication, but override is active.\n"
|
||||
" This is a DANGEROUS configuration. Remote Code Execution is possible if port is accessible."
|
||||
@@ -122,7 +160,7 @@ class SecurityGate:
|
||||
details={
|
||||
"reason": "exposed_without_auth",
|
||||
"override": True,
|
||||
"profile": get_runtime_profile().value,
|
||||
"profile": runtime_profile_value,
|
||||
},
|
||||
)
|
||||
# Do NOT block startup (S45 Override Contract)
|
||||
@@ -138,7 +176,12 @@ class SecurityGate:
|
||||
# Loopback + No Auth
|
||||
# Use strict is_auth_configured (Admin) for Hardened profile loopback check?
|
||||
# "HARDENED profile requires Authentication even on loopback."
|
||||
if is_hardened_mode() and not is_auth_configured():
|
||||
admin_ready = (
|
||||
posture.admin_token_configured
|
||||
if posture is not None
|
||||
else is_auth_configured()
|
||||
)
|
||||
if hardened and not admin_ready:
|
||||
warnings.append(
|
||||
"HARDENED profile requires Admin Authentication even on loopback."
|
||||
)
|
||||
@@ -146,16 +189,24 @@ class SecurityGate:
|
||||
warnings.append("Could not import access_control service")
|
||||
|
||||
# 2. Egress Policy (SSRF)
|
||||
from .runtime_config import get_config
|
||||
if posture is None:
|
||||
from .runtime_config import get_config
|
||||
|
||||
config = get_config()
|
||||
config = get_config()
|
||||
allow_any_public_llm_host = config.allow_any_public_llm_host
|
||||
allow_insecure_base_url = config.allow_insecure_base_url
|
||||
webhook_auth_mode = config.webhook_auth_mode
|
||||
else:
|
||||
allow_any_public_llm_host = posture.allow_any_public_llm_host
|
||||
allow_insecure_base_url = posture.allow_insecure_base_url
|
||||
webhook_auth_mode = posture.webhook_auth_mode
|
||||
|
||||
if config.allow_any_public_llm_host:
|
||||
if allow_any_public_llm_host:
|
||||
warnings.append(
|
||||
"OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST is enabled (Egress check bypassed)"
|
||||
)
|
||||
|
||||
if config.allow_insecure_base_url:
|
||||
if allow_insecure_base_url:
|
||||
warnings.append(
|
||||
"OPENCLAW_ALLOW_INSECURE_BASE_URL is enabled (SSRF check bypassed)"
|
||||
)
|
||||
@@ -164,7 +215,7 @@ class SecurityGate:
|
||||
from .modules import ModuleCapability, is_module_enabled
|
||||
|
||||
if is_module_enabled(ModuleCapability.WEBHOOK):
|
||||
if not config.webhook_auth_mode:
|
||||
if webhook_auth_mode == "unset":
|
||||
warnings.append(
|
||||
"Webhook module enabled but OPENCLAW_WEBHOOK_AUTH_MODE not set"
|
||||
)
|
||||
@@ -206,7 +257,7 @@ class SecurityGate:
|
||||
try:
|
||||
from .control_plane import enforce_control_plane_startup
|
||||
|
||||
cp_result = enforce_control_plane_startup()
|
||||
cp_result = enforce_control_plane_startup(posture=posture)
|
||||
if not cp_result.get("startup_passed", True):
|
||||
for err in cp_result.get("errors", []):
|
||||
fatal_errors.append(f"S62 Control-Plane: {err}")
|
||||
@@ -216,27 +267,41 @@ class SecurityGate:
|
||||
warnings.append("S62 control_plane module failed to import")
|
||||
|
||||
# 7. Connector allowlist fail-closed posture (S71)
|
||||
connector_posture = evaluate_connector_allowlist_posture(os.environ)
|
||||
if connector_posture["has_unguarded_connectors"]:
|
||||
deployment_profile = (
|
||||
os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "").strip().lower()
|
||||
if posture is not None:
|
||||
connector_unguarded = list(posture.connector_unguarded_platforms)
|
||||
connector_allowlist_vars = list(
|
||||
posture.connector_recommended_allowlist_vars
|
||||
)
|
||||
platforms = ", ".join(connector_posture["unguarded_platforms"])
|
||||
allowlist_vars = ", ".join(connector_posture["recommended_allowlist_vars"])
|
||||
else:
|
||||
connector_posture = evaluate_connector_allowlist_posture(os.environ)
|
||||
connector_unguarded = [
|
||||
str(item) for item in connector_posture["unguarded_platforms"]
|
||||
]
|
||||
connector_allowlist_vars = [
|
||||
str(item) for item in connector_posture["recommended_allowlist_vars"]
|
||||
]
|
||||
if connector_unguarded:
|
||||
deployment_profile = (
|
||||
posture.deployment_profile
|
||||
if posture is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "").strip().lower()
|
||||
)
|
||||
platforms = ", ".join(connector_unguarded)
|
||||
allowlist_vars = ", ".join(connector_allowlist_vars)
|
||||
msg = (
|
||||
"Connector allowlist coverage missing for active platform(s): "
|
||||
f"{platforms}. Configure allowlists ({allowlist_vars}) before enabling ingress."
|
||||
)
|
||||
|
||||
# CRITICAL: hardened/public must fail closed for unallowlisted connector ingress.
|
||||
if is_hardened_mode():
|
||||
if hardened:
|
||||
warnings.append(f"S71 (hardened fail-closed): {msg}")
|
||||
_emit_startup_audit(
|
||||
action="startup.connector_allowlist_posture",
|
||||
outcome="error",
|
||||
details={
|
||||
"mode": "hardened",
|
||||
"unguarded_platforms": connector_posture["unguarded_platforms"],
|
||||
"unguarded_platforms": connector_unguarded,
|
||||
"deployment_profile": deployment_profile or "unset",
|
||||
},
|
||||
)
|
||||
@@ -247,7 +312,7 @@ class SecurityGate:
|
||||
outcome="error",
|
||||
details={
|
||||
"mode": "public",
|
||||
"unguarded_platforms": connector_posture["unguarded_platforms"],
|
||||
"unguarded_platforms": connector_unguarded,
|
||||
},
|
||||
)
|
||||
else:
|
||||
@@ -257,13 +322,13 @@ class SecurityGate:
|
||||
outcome="warn",
|
||||
details={
|
||||
"mode": "warn_only",
|
||||
"unguarded_platforms": connector_posture["unguarded_platforms"],
|
||||
"unguarded_platforms": connector_unguarded,
|
||||
"deployment_profile": deployment_profile or "unset",
|
||||
},
|
||||
)
|
||||
|
||||
# In HARDENED mode, treat all warnings as FATAL
|
||||
if is_hardened_mode() and warnings:
|
||||
if hardened and warnings:
|
||||
fatal_errors.extend(warnings)
|
||||
warnings = []
|
||||
|
||||
@@ -271,18 +336,26 @@ class SecurityGate:
|
||||
return passed, warnings, fatal_errors
|
||||
|
||||
|
||||
def enforce_startup_gate() -> None:
|
||||
def enforce_startup_gate(
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the security gate.
|
||||
If in HARDENED mode and checks fail -> Raise SystemExit.
|
||||
If in MINIMAL mode and checks fail -> Log warnings.
|
||||
"""
|
||||
is_hardened = is_hardened_mode()
|
||||
is_hardened = (
|
||||
posture.runtime_profile == "hardened"
|
||||
if posture is not None
|
||||
else is_hardened_mode()
|
||||
)
|
||||
mode_str = "HARDENED" if is_hardened else "MINIMAL"
|
||||
|
||||
logger.info(f"Running S41 Security Gate ({mode_str} profile)...")
|
||||
|
||||
passed, warnings, fatal_errors = SecurityGate.verify_mandatory_controls()
|
||||
passed, warnings, fatal_errors = SecurityGate.verify_mandatory_controls(
|
||||
posture=posture
|
||||
)
|
||||
|
||||
# Log warnings first (non-blocking unless hardened)
|
||||
if warnings:
|
||||
|
||||
+46
-205
@@ -1,214 +1,55 @@
|
||||
"""
|
||||
Startup lifecycle diagnostics and optional warmup boundaries.
|
||||
|
||||
Required startup work still fails closed in callers. This module only tracks
|
||||
readiness and runs optional warmups without delaying route availability.
|
||||
"""
|
||||
"""Compatibility alias for the bootstrap lifecycle implementation module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Iterable, Optional
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.services.startup_lifecycle")
|
||||
from .bootstrap import lifecycle as _implementation
|
||||
|
||||
STARTUP_STARTING = "starting"
|
||||
STARTUP_READY = "ready"
|
||||
STARTUP_DEGRADED_WARMUP = "degraded-warmup"
|
||||
STARTUP_FATAL = "fatal-startup"
|
||||
|
||||
WARMUP_PENDING = "pending"
|
||||
WARMUP_RUNNING = "running"
|
||||
WARMUP_SUCCEEDED = "succeeded"
|
||||
WARMUP_FAILED = "failed"
|
||||
WARMUP_TIMED_OUT = "timed_out"
|
||||
|
||||
WarmupSpec = tuple[str, Callable[[], Any], float]
|
||||
|
||||
_LOCK = threading.RLock()
|
||||
_STARTED_AT = time.time()
|
||||
_READY = False
|
||||
_READY_PHASE: Optional[str] = None
|
||||
_READY_AT: Optional[float] = None
|
||||
_FATAL: Optional[Dict[str, Any]] = None
|
||||
_WARMUPS: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def mark_startup_ready(phase: str = "routes") -> None:
|
||||
"""Mark required startup work as ready."""
|
||||
global _READY, _READY_AT, _READY_PHASE
|
||||
with _LOCK:
|
||||
if _FATAL is not None:
|
||||
return
|
||||
_READY = True
|
||||
_READY_PHASE = str(phase or "routes")
|
||||
_READY_AT = time.time()
|
||||
|
||||
|
||||
def mark_startup_fatal(phase: str, exc: BaseException) -> None:
|
||||
"""Record a fatal required-startup failure."""
|
||||
global _FATAL, _READY
|
||||
with _LOCK:
|
||||
_READY = False
|
||||
_FATAL = {
|
||||
"phase": str(phase or "startup"),
|
||||
"error_type": type(exc).__name__,
|
||||
"error": str(exc)[:500],
|
||||
"ts": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def start_optional_warmups(specs: Iterable[WarmupSpec]) -> None:
|
||||
"""Start optional warmups in background monitor threads."""
|
||||
for name, fn, timeout_sec in list(specs or []):
|
||||
_start_optional_warmup(str(name), fn, float(timeout_sec))
|
||||
|
||||
|
||||
def get_startup_diagnostics() -> Dict[str, Any]:
|
||||
"""Return a bounded diagnostic snapshot for health/operator views."""
|
||||
with _LOCK:
|
||||
warmups = {name: dict(record) for name, record in _WARMUPS.items()}
|
||||
fatal = dict(_FATAL) if _FATAL else None
|
||||
ready = bool(_READY and fatal is None)
|
||||
degraded = any(
|
||||
record.get("state") in {WARMUP_FAILED, WARMUP_TIMED_OUT}
|
||||
for record in warmups.values()
|
||||
)
|
||||
if fatal:
|
||||
state = STARTUP_FATAL
|
||||
elif ready and degraded:
|
||||
state = STARTUP_DEGRADED_WARMUP
|
||||
elif ready:
|
||||
state = STARTUP_READY
|
||||
else:
|
||||
state = STARTUP_STARTING
|
||||
return {
|
||||
"state": state,
|
||||
"ready": ready,
|
||||
"ready_phase": _READY_PHASE,
|
||||
"started_at": _STARTED_AT,
|
||||
"ready_at": _READY_AT,
|
||||
"fatal": fatal,
|
||||
"warmups": warmups,
|
||||
}
|
||||
|
||||
|
||||
def reset_startup_lifecycle_for_tests() -> None:
|
||||
"""Reset in-memory lifecycle state for tests."""
|
||||
global _READY, _READY_AT, _READY_PHASE, _FATAL, _STARTED_AT
|
||||
with _LOCK:
|
||||
_STARTED_AT = time.time()
|
||||
_READY = False
|
||||
_READY_PHASE = None
|
||||
_READY_AT = None
|
||||
_FATAL = None
|
||||
_WARMUPS.clear()
|
||||
|
||||
|
||||
def _start_optional_warmup(
|
||||
name: str, fn: Callable[[], Any], timeout_sec: float
|
||||
) -> None:
|
||||
timeout_sec = max(0.01, min(float(timeout_sec or 5.0), 60.0))
|
||||
with _LOCK:
|
||||
existing = _WARMUPS.get(name)
|
||||
if existing and existing.get("state") in {WARMUP_RUNNING, WARMUP_SUCCEEDED}:
|
||||
return
|
||||
_WARMUPS[name] = {
|
||||
"state": WARMUP_PENDING,
|
||||
"timeout_sec": timeout_sec,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"duration_sec": None,
|
||||
"error_type": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
monitor = threading.Thread(
|
||||
target=_warmup_monitor,
|
||||
args=(name, fn, timeout_sec),
|
||||
name=f"openclaw-warmup-monitor-{name}",
|
||||
daemon=True,
|
||||
if TYPE_CHECKING:
|
||||
# Static-only exports keep legacy imports typed without a second runtime owner.
|
||||
from .bootstrap.lifecycle import MAX_DIAGNOSTIC_MS as MAX_DIAGNOSTIC_MS
|
||||
from .bootstrap.lifecycle import MAX_WARMUPS as MAX_WARMUPS
|
||||
from .bootstrap.lifecycle import SCHEMA_VERSION as SCHEMA_VERSION
|
||||
from .bootstrap.lifecycle import STARTUP_DEGRADED_WARMUP as STARTUP_DEGRADED_WARMUP
|
||||
from .bootstrap.lifecycle import STARTUP_DIAGNOSTIC_KEYS as STARTUP_DIAGNOSTIC_KEYS
|
||||
from .bootstrap.lifecycle import STARTUP_FATAL as STARTUP_FATAL
|
||||
from .bootstrap.lifecycle import STARTUP_READY as STARTUP_READY
|
||||
from .bootstrap.lifecycle import STARTUP_STARTING as STARTUP_STARTING
|
||||
from .bootstrap.lifecycle import WARMUP_FAILED as WARMUP_FAILED
|
||||
from .bootstrap.lifecycle import WARMUP_PENDING as WARMUP_PENDING
|
||||
from .bootstrap.lifecycle import WARMUP_RUNNING as WARMUP_RUNNING
|
||||
from .bootstrap.lifecycle import WARMUP_SUCCEEDED as WARMUP_SUCCEEDED
|
||||
from .bootstrap.lifecycle import WARMUP_TIMED_OUT as WARMUP_TIMED_OUT
|
||||
from .bootstrap.lifecycle import StartupLifecycle as StartupLifecycle
|
||||
from .bootstrap.lifecycle import StartupOutcome as StartupOutcome
|
||||
from .bootstrap.lifecycle import StartupPhase as StartupPhase
|
||||
from .bootstrap.lifecycle import StartupReason as StartupReason
|
||||
from .bootstrap.lifecycle import StartupState as StartupState
|
||||
from .bootstrap.lifecycle import StartupTransitionError as StartupTransitionError
|
||||
from .bootstrap.lifecycle import WarmupOutcome as WarmupOutcome
|
||||
from .bootstrap.lifecycle import WarmupSpec as WarmupSpec
|
||||
from .bootstrap.lifecycle import WarmupState as WarmupState
|
||||
from .bootstrap.lifecycle import get_startup_diagnostics as get_startup_diagnostics
|
||||
from .bootstrap.lifecycle import get_startup_outcome as get_startup_outcome
|
||||
from .bootstrap.lifecycle import (
|
||||
mark_bootstrap_import_failed as mark_bootstrap_import_failed,
|
||||
)
|
||||
monitor.start()
|
||||
|
||||
|
||||
def _warmup_monitor(name: str, fn: Callable[[], Any], timeout_sec: float) -> None:
|
||||
started_at = time.time()
|
||||
done = threading.Event()
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
result["value"] = fn()
|
||||
result["ok"] = True
|
||||
except Exception as exc: # pragma: no cover - defensive outer guard
|
||||
result["ok"] = False
|
||||
result["exc"] = exc
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
with _LOCK:
|
||||
if name in _WARMUPS:
|
||||
_WARMUPS[name]["state"] = WARMUP_RUNNING
|
||||
_WARMUPS[name]["started_at"] = started_at
|
||||
|
||||
worker = threading.Thread(
|
||||
target=_worker,
|
||||
name=f"openclaw-warmup-{name}",
|
||||
daemon=True,
|
||||
from .bootstrap.lifecycle import mark_host_waiting as mark_host_waiting
|
||||
from .bootstrap.lifecycle import (
|
||||
mark_required_initialization_started as mark_required_initialization_started,
|
||||
)
|
||||
worker.start()
|
||||
|
||||
if not done.wait(timeout=timeout_sec):
|
||||
_finish_warmup(
|
||||
name,
|
||||
WARMUP_TIMED_OUT,
|
||||
started_at,
|
||||
error_type="TimeoutError",
|
||||
error=f"optional warmup exceeded {timeout_sec:.2f}s",
|
||||
)
|
||||
logger.warning(
|
||||
"R188: optional startup warmup timed out: %s (%.2fs)",
|
||||
name,
|
||||
timeout_sec,
|
||||
)
|
||||
return
|
||||
|
||||
exc = result.get("exc")
|
||||
if result.get("ok"):
|
||||
_finish_warmup(name, WARMUP_SUCCEEDED, started_at)
|
||||
logger.info("R188: optional startup warmup completed: %s", name)
|
||||
return
|
||||
|
||||
_finish_warmup(
|
||||
name,
|
||||
WARMUP_FAILED,
|
||||
started_at,
|
||||
error_type=type(exc).__name__ if exc else "Exception",
|
||||
error=str(exc)[:500] if exc else "unknown warmup failure",
|
||||
from .bootstrap.lifecycle import mark_retry_exhausted as mark_retry_exhausted
|
||||
from .bootstrap.lifecycle import (
|
||||
mark_route_registration_started as mark_route_registration_started,
|
||||
)
|
||||
logger.warning("R188: optional startup warmup failed: %s: %s", name, exc)
|
||||
from .bootstrap.lifecycle import mark_startup_fatal as mark_startup_fatal
|
||||
from .bootstrap.lifecycle import mark_startup_ready as mark_startup_ready
|
||||
from .bootstrap.lifecycle import (
|
||||
reset_startup_lifecycle_for_tests as reset_startup_lifecycle_for_tests,
|
||||
)
|
||||
from .bootstrap.lifecycle import start_optional_warmups as start_optional_warmups
|
||||
|
||||
|
||||
def _finish_warmup(
|
||||
name: str,
|
||||
state: str,
|
||||
started_at: float,
|
||||
*,
|
||||
error_type: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
completed_at = time.time()
|
||||
with _LOCK:
|
||||
record = _WARMUPS.setdefault(name, {})
|
||||
record.update(
|
||||
{
|
||||
"state": state,
|
||||
"completed_at": completed_at,
|
||||
"duration_sec": max(0.0, completed_at - started_at),
|
||||
"error_type": error_type,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
# IMPORTANT: alias the module object; copied re-exports split singleton and patch state.
|
||||
sys.modules[__name__] = _implementation
|
||||
|
||||
@@ -21,7 +21,10 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .effective_security_posture import EffectiveSecurityPosture
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.services.startup_profile_gate")
|
||||
|
||||
@@ -80,12 +83,14 @@ _PROFILE_ENV = "OPENCLAW_DEPLOYMENT_PROFILE"
|
||||
|
||||
def _resolve_profile(environ: Optional[Mapping[str, str]] = None) -> str:
|
||||
"""Resolve the deployment profile from environment."""
|
||||
env = environ or os.environ
|
||||
env = os.environ if environ is None else environ
|
||||
return env.get(_PROFILE_ENV, "local").strip().lower()
|
||||
|
||||
|
||||
def evaluate_startup_gate(
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> StartupGateResult:
|
||||
"""
|
||||
Evaluate the startup profile gate.
|
||||
@@ -94,7 +99,37 @@ def evaluate_startup_gate(
|
||||
override status.
|
||||
"""
|
||||
global _last_gate_result # noqa: PLW0603
|
||||
env: Mapping[str, str] = environ or os.environ
|
||||
if posture is not None:
|
||||
violation_codes = set(posture.startup_profile_violation_codes)
|
||||
violations = [
|
||||
{
|
||||
"code": check.code,
|
||||
"severity": check.severity,
|
||||
"message": check.message,
|
||||
"remediation": check.remediation,
|
||||
}
|
||||
for check in posture.deployment_checks
|
||||
if check.code in violation_codes
|
||||
]
|
||||
override_reason = ""
|
||||
if posture.startup_profile_overridden:
|
||||
override_reason = (
|
||||
f"S56: Startup gate bypassed via {_OVERRIDE_ENV}=1. "
|
||||
f"Profile '{posture.deployment_profile}' has "
|
||||
f"{len(violations)} violation(s). "
|
||||
"This override is intended for emergency use only."
|
||||
)
|
||||
result = StartupGateResult(
|
||||
profile=posture.deployment_profile,
|
||||
passed=posture.startup_profile_passed,
|
||||
overridden=posture.startup_profile_overridden,
|
||||
override_reason=override_reason,
|
||||
violations=violations,
|
||||
)
|
||||
_last_gate_result = result
|
||||
return result
|
||||
|
||||
env: Mapping[str, str] = os.environ if environ is None else environ
|
||||
profile = _resolve_profile(env)
|
||||
|
||||
# Local profile: no enforcement
|
||||
@@ -149,6 +184,8 @@ def evaluate_startup_gate(
|
||||
|
||||
def enforce_startup_gate(
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
*,
|
||||
posture: EffectiveSecurityPosture | None = None,
|
||||
) -> StartupGateResult:
|
||||
"""
|
||||
Evaluate and enforce the startup profile gate.
|
||||
@@ -158,7 +195,7 @@ def enforce_startup_gate(
|
||||
|
||||
Returns the gate result on success (pass or overridden).
|
||||
"""
|
||||
result = evaluate_startup_gate(environ)
|
||||
result = evaluate_startup_gate(environ, posture=posture)
|
||||
|
||||
if result.passed and not result.overridden:
|
||||
logger.info(f"S56: Startup profile gate PASSED for profile '{result.profile}'.")
|
||||
|
||||
@@ -32,8 +32,23 @@ web = import_aiohttp_web()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_installed_posture():
|
||||
try:
|
||||
from .effective_security_posture import get_effective_security_posture
|
||||
|
||||
return get_effective_security_posture(required=False)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_fail_closed_profile() -> bool:
|
||||
"""Return True if errors should fail-closed (block) rather than fail-open."""
|
||||
posture = _get_installed_posture()
|
||||
if posture is not None:
|
||||
return bool(
|
||||
posture.deployment_profile == "public"
|
||||
or posture.runtime_profile == "hardened"
|
||||
)
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local").lower()
|
||||
if profile == "public":
|
||||
return True
|
||||
@@ -62,9 +77,14 @@ def check_surface(surface_id: str, request: web.Request = None) -> web.Response
|
||||
try:
|
||||
from .control_plane import get_blocked_surfaces, resolve_control_plane_mode
|
||||
|
||||
profile = os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
mode = resolve_control_plane_mode(profile)
|
||||
blocked = get_blocked_surfaces(profile, mode)
|
||||
posture = _get_installed_posture()
|
||||
profile = (
|
||||
posture.deployment_profile
|
||||
if posture is not None
|
||||
else os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "local")
|
||||
)
|
||||
mode = resolve_control_plane_mode(profile, posture=posture)
|
||||
blocked = get_blocked_surfaces(profile, mode, posture=posture)
|
||||
blocked_ids = {sid: desc for sid, desc in blocked}
|
||||
|
||||
if surface_id not in blocked_ids:
|
||||
|
||||
+11
-2
@@ -249,7 +249,10 @@ Then every `git push` will run:
|
||||
bash scripts/pre_push_checks.sh
|
||||
```
|
||||
|
||||
`scripts/pre_push_checks.sh` is the CI-parity guard and must include all 9 stages:
|
||||
`scripts/pre_push_checks.sh` is the CI-parity guard and must reconcile the exact
|
||||
frontend lockfile with `npm ci`, then block high-severity findings across production
|
||||
and development dependencies with `npm audit --audit-level=high`, before the 9
|
||||
behavioral and governance stages:
|
||||
|
||||
1) `detect-secrets`
|
||||
2) all `pre-commit` hooks
|
||||
@@ -288,13 +291,15 @@ IMPORTANT:
|
||||
## Coverage Governance Baseline (Required)
|
||||
|
||||
- Coverage configuration lives in `pyproject.toml` and must keep:
|
||||
- `fail_under >= 45.0`
|
||||
- `fail_under >= 55.0`
|
||||
- `show_missing = true`
|
||||
- `skip_covered = true`
|
||||
- staged coverage ratchet policy (`tests/coverage_governance_policy.json`) lives in the repo and governs the current floor plus future ratchet targets.
|
||||
- `pyproject.toml` must keep `fail_under` aligned with the current stage floor declared there.
|
||||
- Planned ratchet targets are governance metadata, not implicit permission to change `fail_under` ad hoc.
|
||||
- retained promotion evidence lives in `tests/coverage_promotion_reviews.json`.
|
||||
- the ratchet-55 promotion requires two consecutive ratchet-45 release-cycle reviews with
|
||||
immutable commit/artifact identity, all required hotspot percentages, and owned suites.
|
||||
- backend unit coverage should be gathered with:
|
||||
|
||||
```bash
|
||||
@@ -391,6 +396,10 @@ node -v
|
||||
# node -v
|
||||
#
|
||||
# IMPORTANT: run `npm ci` with the same Node version you use for `npm test`.
|
||||
npm ci
|
||||
|
||||
# Development tooling is part of the acceptance trust boundary.
|
||||
npm audit --audit-level=high
|
||||
|
||||
# One-time browser install (recommended)
|
||||
npx playwright install chromium
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"allowed_llm_keys": [
|
||||
"allow_private_network",
|
||||
"base_url",
|
||||
"fallback_models",
|
||||
"fallback_providers",
|
||||
"max_failover_candidates",
|
||||
"max_retries",
|
||||
"model",
|
||||
"provider",
|
||||
"timeout_sec"
|
||||
],
|
||||
"apply_semantics": {
|
||||
"base_url": {
|
||||
"applied_now": [
|
||||
"base_url"
|
||||
],
|
||||
"notes": [],
|
||||
"restart_required": []
|
||||
},
|
||||
"model": {
|
||||
"applied_now": [
|
||||
"model"
|
||||
],
|
||||
"notes": [],
|
||||
"restart_required": []
|
||||
},
|
||||
"provider": {
|
||||
"applied_now": [
|
||||
"provider"
|
||||
],
|
||||
"notes": [],
|
||||
"restart_required": []
|
||||
}
|
||||
},
|
||||
"facade_metadata": {
|
||||
"config_get_handler": {
|
||||
"audit": "config.read",
|
||||
"auth": "obs",
|
||||
"description": "Returns effective config, sources, and provider catalog.",
|
||||
"plane": "admin",
|
||||
"risk": "low",
|
||||
"scopes": [],
|
||||
"summary": "Get configuration"
|
||||
},
|
||||
"config_put_handler": {
|
||||
"audit": "config.update",
|
||||
"auth": "admin",
|
||||
"description": "Updates non-secret LLM config.",
|
||||
"plane": "admin",
|
||||
"risk": "high",
|
||||
"scopes": [],
|
||||
"summary": "Update configuration"
|
||||
},
|
||||
"llm_chat_handler": {
|
||||
"audit": "llm.chat_completion",
|
||||
"auth": "admin",
|
||||
"description": "Run a simple chat completion using server-side LLM config.",
|
||||
"plane": "admin",
|
||||
"risk": "medium",
|
||||
"scopes": [],
|
||||
"summary": "Chat completion"
|
||||
},
|
||||
"llm_models_handler": {
|
||||
"audit": "llm.list_models",
|
||||
"auth": "admin",
|
||||
"description": "Fetch a remote model list (best-effort) for OpenAI-compatible providers.",
|
||||
"plane": "admin",
|
||||
"risk": "low",
|
||||
"scopes": [],
|
||||
"summary": "List remote models"
|
||||
},
|
||||
"llm_test_handler": {
|
||||
"audit": "llm.test_connection",
|
||||
"auth": "admin",
|
||||
"description": "Tests LLM connection using provided or stored credentials.",
|
||||
"plane": "admin",
|
||||
"risk": "medium",
|
||||
"scopes": [],
|
||||
"summary": "Test LLM connection"
|
||||
}
|
||||
},
|
||||
"facade_signatures": {
|
||||
"config_get_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"config_put_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"llm_chat_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"llm_models_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"llm_test_handler": "(request: 'web.Request') -> 'web.Response'"
|
||||
},
|
||||
"model_cache": {
|
||||
"exported_cache_type": "OrderedDict",
|
||||
"max_entries": 16,
|
||||
"ttl_sec": 600
|
||||
},
|
||||
"openapi_sha256": "7997883a91ca7b3e3058d3c299512947ca634d92ddd1aa4c06f0d33539325f33",
|
||||
"owned_response_matrices": {
|
||||
"config": [
|
||||
"tests.test_s66_api_config_guardrails",
|
||||
"tests.test_r53_apply_semantics",
|
||||
"tests.security.test_r99_sensitive_contract",
|
||||
"tests.test_r219_exception_boundary_phase2"
|
||||
],
|
||||
"llm": [
|
||||
"tests.test_s28s29_chat_csrf_redaction",
|
||||
"tests.test_r219_exception_boundary_phase2"
|
||||
],
|
||||
"models": [
|
||||
"tests.test_api_model_list",
|
||||
"tests.test_r60_model_cache",
|
||||
"tests.test_r123_real_backend_model_list_lane",
|
||||
"tests.test_r155_exception_fidelity",
|
||||
"tests.test_llm_default_allowlist"
|
||||
]
|
||||
},
|
||||
"patch_seams": [
|
||||
"web",
|
||||
"logger",
|
||||
"require_observability_access",
|
||||
"require_admin_token",
|
||||
"require_same_origin_if_no_token",
|
||||
"check_rate_limit",
|
||||
"build_rate_limit_response",
|
||||
"resolve_token_info",
|
||||
"emit_audit_event",
|
||||
"request_tenant_scope",
|
||||
"get_effective_config",
|
||||
"get_runtime_guardrails",
|
||||
"get_settings_schema",
|
||||
"update_config",
|
||||
"get_apply_semantics",
|
||||
"get_admin_token",
|
||||
"payload_contains_runtime_guardrails",
|
||||
"get_llm_egress_controls",
|
||||
"is_loopback_client",
|
||||
"get_client_ip",
|
||||
"resolve_model_list_target",
|
||||
"validate_model_list_target",
|
||||
"fetch_remote_model_list",
|
||||
"get_stale_cached_models",
|
||||
"_cache_get",
|
||||
"_format_llm_ssrf_error",
|
||||
"_llm_insecure_override_enabled",
|
||||
"LLMClient"
|
||||
],
|
||||
"provider_catalog": [
|
||||
{
|
||||
"id": "openai",
|
||||
"label": "OpenAI",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"label": "Anthropic",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "openrouter",
|
||||
"label": "OpenRouter",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"label": "Gemini (OpenAI-compat)",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"label": "Groq",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"label": "DeepSeek",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "xai",
|
||||
"label": "xAI",
|
||||
"requires_key": true
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"label": "Ollama (Local)",
|
||||
"requires_key": false
|
||||
},
|
||||
{
|
||||
"id": "lmstudio",
|
||||
"label": "LM Studio (Local)",
|
||||
"requires_key": false
|
||||
},
|
||||
{
|
||||
"id": "antigravity_proxy",
|
||||
"label": "Antigravity Claude Proxy (Local)",
|
||||
"requires_key": false
|
||||
},
|
||||
{
|
||||
"id": "custom",
|
||||
"label": "Custom",
|
||||
"requires_key": true
|
||||
}
|
||||
],
|
||||
"r220_route_contract_sha256": "947979f978f3541dce6b005c38255177322234a24b47ee0dd22a49e234eeed27",
|
||||
"schema_version": 1,
|
||||
"settings_schema_sha256": "e129472bd8b4fb81181c2a3169ed6177757cb54050276646eea70052007bd10b"
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
{
|
||||
"direct_alias_rule": "each registered path also attempts path and /api+path",
|
||||
"facade_metadata": {
|
||||
"health_handler": {
|
||||
"audit": "health.check",
|
||||
"auth": "public",
|
||||
"description": "Returns pack status, uptime, dependencies, and stats.",
|
||||
"plane": "user",
|
||||
"risk": "low",
|
||||
"scopes": [],
|
||||
"summary": "Health check"
|
||||
},
|
||||
"jobs_handler": {
|
||||
"audit": "jobs.list",
|
||||
"auth": "admin",
|
||||
"description": "Admin-authorized versioned bounded in-process jobs read model.",
|
||||
"plane": "admin",
|
||||
"risk": "low",
|
||||
"scopes": [],
|
||||
"summary": "List jobs"
|
||||
},
|
||||
"logs_tail_handler": {
|
||||
"audit": "logs.tail",
|
||||
"auth": "admin",
|
||||
"description": "Returns the last N lines of the log file.",
|
||||
"plane": "admin",
|
||||
"risk": "medium",
|
||||
"scopes": [],
|
||||
"summary": "Tail logs"
|
||||
},
|
||||
"trace_handler": {
|
||||
"audit": "trace.get",
|
||||
"auth": "admin",
|
||||
"description": "Returns redacted timeline for a prompt.",
|
||||
"plane": "admin",
|
||||
"risk": "medium",
|
||||
"scopes": [],
|
||||
"summary": "Get trace"
|
||||
}
|
||||
},
|
||||
"facade_signatures": {
|
||||
"_emit_jobs_list_audit": "(*, request, token_info, outcome: 'str', status_code: 'int', reason: 'str', **counts) -> 'None'",
|
||||
"_ensure_observability_deps_ready": "() -> 'tuple[bool, str | None]'",
|
||||
"_resolve_mae_profile": "() -> 'str'",
|
||||
"_run_mae_startup_gate": "(server) -> 'None'",
|
||||
"health_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"jobs_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"logs_tail_handler": "(request: 'web.Request') -> 'web.Response'",
|
||||
"register_dual_route": "(server, method: 'str', path: 'str', handler) -> 'None'",
|
||||
"register_routes": "(server) -> 'None'",
|
||||
"trace_handler": "(request: 'web.Request') -> 'web.Response'"
|
||||
},
|
||||
"families": {
|
||||
"assist:/moltbot": [
|
||||
{
|
||||
"handler": "planner_profiles_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/assist/planner/profiles"
|
||||
},
|
||||
{
|
||||
"handler": "planner_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/assist/planner"
|
||||
},
|
||||
{
|
||||
"handler": "planner_stream_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/assist/planner/stream"
|
||||
},
|
||||
{
|
||||
"handler": "refiner_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/assist/refiner"
|
||||
},
|
||||
{
|
||||
"handler": "refiner_stream_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/assist/refiner/stream"
|
||||
},
|
||||
{
|
||||
"handler": "compose_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/assist/automation/compose"
|
||||
}
|
||||
],
|
||||
"assist:/openclaw": [
|
||||
{
|
||||
"handler": "planner_profiles_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/assist/planner/profiles"
|
||||
},
|
||||
{
|
||||
"handler": "planner_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/assist/planner"
|
||||
},
|
||||
{
|
||||
"handler": "planner_stream_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/assist/planner/stream"
|
||||
},
|
||||
{
|
||||
"handler": "refiner_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/assist/refiner"
|
||||
},
|
||||
{
|
||||
"handler": "refiner_stream_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/assist/refiner/stream"
|
||||
},
|
||||
{
|
||||
"handler": "compose_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/assist/automation/compose"
|
||||
}
|
||||
],
|
||||
"connector_installations:/moltbot": [
|
||||
{
|
||||
"handler": "connector_installations_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/connector/installations"
|
||||
},
|
||||
{
|
||||
"handler": "connector_extraction_contract_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/connector/extraction-contract"
|
||||
},
|
||||
{
|
||||
"handler": "connector_installation_resolve_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/connector/installations/resolve"
|
||||
},
|
||||
{
|
||||
"handler": "connector_installation_audit_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/connector/installations/audit"
|
||||
},
|
||||
{
|
||||
"handler": "connector_installation_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/connector/installations/{installation_id}"
|
||||
}
|
||||
],
|
||||
"connector_installations:/openclaw": [
|
||||
{
|
||||
"handler": "connector_installations_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/connector/installations"
|
||||
},
|
||||
{
|
||||
"handler": "connector_extraction_contract_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/connector/extraction-contract"
|
||||
},
|
||||
{
|
||||
"handler": "connector_installation_resolve_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/connector/installations/resolve"
|
||||
},
|
||||
{
|
||||
"handler": "connector_installation_audit_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/connector/installations/audit"
|
||||
},
|
||||
{
|
||||
"handler": "connector_installation_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/connector/installations/{installation_id}"
|
||||
}
|
||||
],
|
||||
"core:/moltbot": [
|
||||
{
|
||||
"handler": "remote_admin_page_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/admin"
|
||||
},
|
||||
{
|
||||
"handler": "health_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/health"
|
||||
},
|
||||
{
|
||||
"handler": "logs_tail_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/logs/tail"
|
||||
},
|
||||
{
|
||||
"handler": "jobs_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/jobs"
|
||||
},
|
||||
{
|
||||
"handler": "trace_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/trace/{prompt_id}"
|
||||
},
|
||||
{
|
||||
"handler": "webhook_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/webhook"
|
||||
},
|
||||
{
|
||||
"handler": "webhook_submit_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/webhook/submit"
|
||||
},
|
||||
{
|
||||
"handler": "webhook_validate_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/webhook/validate"
|
||||
},
|
||||
{
|
||||
"handler": "capabilities_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/capabilities"
|
||||
},
|
||||
{
|
||||
"handler": "config_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/config"
|
||||
},
|
||||
{
|
||||
"handler": "config_put_handler",
|
||||
"method": "PUT",
|
||||
"path": "/moltbot/config"
|
||||
},
|
||||
{
|
||||
"handler": "llm_test_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/llm/test"
|
||||
},
|
||||
{
|
||||
"handler": "llm_chat_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/llm/chat"
|
||||
},
|
||||
{
|
||||
"handler": "llm_models_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/llm/models"
|
||||
},
|
||||
{
|
||||
"handler": "templates_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/templates"
|
||||
},
|
||||
{
|
||||
"handler": "preflight_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/preflight"
|
||||
},
|
||||
{
|
||||
"handler": "inventory_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/preflight/inventory"
|
||||
},
|
||||
{
|
||||
"handler": "pnginfo_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/pnginfo"
|
||||
},
|
||||
{
|
||||
"handler": "list_checkpoints_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/checkpoints"
|
||||
},
|
||||
{
|
||||
"handler": "create_checkpoint_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/checkpoints"
|
||||
},
|
||||
{
|
||||
"handler": "get_checkpoint_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/checkpoints/{id}"
|
||||
},
|
||||
{
|
||||
"handler": "delete_checkpoint_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/moltbot/checkpoints/{id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipes_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/rewrite/recipes"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_create_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/rewrite/recipes"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/rewrite/recipes/{recipe_id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_update_handler",
|
||||
"method": "PUT",
|
||||
"path": "/moltbot/rewrite/recipes/{recipe_id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_delete_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/moltbot/rewrite/recipes/{recipe_id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_dry_run_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/rewrite/recipes/{recipe_id}/dry-run"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_apply_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/rewrite/recipes/{recipe_id}/apply"
|
||||
},
|
||||
{
|
||||
"handler": "model_search_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/models/search"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_create_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/models/downloads"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/models/downloads"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/models/downloads/{task_id}"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_cancel_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/models/downloads/{task_id}/cancel"
|
||||
},
|
||||
{
|
||||
"handler": "model_import_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/models/import"
|
||||
},
|
||||
{
|
||||
"handler": "model_installations_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/models/installations"
|
||||
},
|
||||
{
|
||||
"handler": "secrets_status_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/secrets/status"
|
||||
},
|
||||
{
|
||||
"handler": "secrets_put_handler",
|
||||
"method": "PUT",
|
||||
"path": "/moltbot/secrets"
|
||||
},
|
||||
{
|
||||
"handler": "events_stream_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/events/stream"
|
||||
},
|
||||
{
|
||||
"handler": "events_poll_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/events"
|
||||
},
|
||||
{
|
||||
"handler": "secrets_delete_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/moltbot/secrets/{provider}"
|
||||
},
|
||||
{
|
||||
"handler": "security_doctor_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/security/doctor"
|
||||
},
|
||||
{
|
||||
"handler": "tools_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/tools"
|
||||
},
|
||||
{
|
||||
"handler": "tools_run_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/tools/{name}/run"
|
||||
},
|
||||
{
|
||||
"handler": "create_sweep_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/lab/sweep"
|
||||
},
|
||||
{
|
||||
"handler": "create_compare_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/lab/compare"
|
||||
},
|
||||
{
|
||||
"handler": "list_experiments_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/lab/experiments"
|
||||
},
|
||||
{
|
||||
"handler": "get_experiment_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/lab/experiments/{exp_id}"
|
||||
},
|
||||
{
|
||||
"handler": "update_experiment_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/lab/experiments/{exp_id}/runs/{run_id}"
|
||||
},
|
||||
{
|
||||
"handler": "select_apply_winner_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/lab/experiments/{exp_id}/winner"
|
||||
}
|
||||
],
|
||||
"core:/openclaw": [
|
||||
{
|
||||
"handler": "remote_admin_page_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/admin"
|
||||
},
|
||||
{
|
||||
"handler": "health_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/health"
|
||||
},
|
||||
{
|
||||
"handler": "logs_tail_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/logs/tail"
|
||||
},
|
||||
{
|
||||
"handler": "jobs_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/jobs"
|
||||
},
|
||||
{
|
||||
"handler": "trace_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/trace/{prompt_id}"
|
||||
},
|
||||
{
|
||||
"handler": "webhook_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/webhook"
|
||||
},
|
||||
{
|
||||
"handler": "webhook_submit_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/webhook/submit"
|
||||
},
|
||||
{
|
||||
"handler": "webhook_validate_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/webhook/validate"
|
||||
},
|
||||
{
|
||||
"handler": "capabilities_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/capabilities"
|
||||
},
|
||||
{
|
||||
"handler": "config_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/config"
|
||||
},
|
||||
{
|
||||
"handler": "config_put_handler",
|
||||
"method": "PUT",
|
||||
"path": "/openclaw/config"
|
||||
},
|
||||
{
|
||||
"handler": "llm_test_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/llm/test"
|
||||
},
|
||||
{
|
||||
"handler": "llm_chat_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/llm/chat"
|
||||
},
|
||||
{
|
||||
"handler": "llm_models_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/llm/models"
|
||||
},
|
||||
{
|
||||
"handler": "templates_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/templates"
|
||||
},
|
||||
{
|
||||
"handler": "preflight_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/preflight"
|
||||
},
|
||||
{
|
||||
"handler": "inventory_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/preflight/inventory"
|
||||
},
|
||||
{
|
||||
"handler": "pnginfo_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/pnginfo"
|
||||
},
|
||||
{
|
||||
"handler": "list_checkpoints_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/checkpoints"
|
||||
},
|
||||
{
|
||||
"handler": "create_checkpoint_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/checkpoints"
|
||||
},
|
||||
{
|
||||
"handler": "get_checkpoint_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/checkpoints/{id}"
|
||||
},
|
||||
{
|
||||
"handler": "delete_checkpoint_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/openclaw/checkpoints/{id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipes_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/rewrite/recipes"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_create_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/rewrite/recipes"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/rewrite/recipes/{recipe_id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_update_handler",
|
||||
"method": "PUT",
|
||||
"path": "/openclaw/rewrite/recipes/{recipe_id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_delete_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/openclaw/rewrite/recipes/{recipe_id}"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_dry_run_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/rewrite/recipes/{recipe_id}/dry-run"
|
||||
},
|
||||
{
|
||||
"handler": "rewrite_recipe_apply_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/rewrite/recipes/{recipe_id}/apply"
|
||||
},
|
||||
{
|
||||
"handler": "model_search_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/models/search"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_create_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/models/downloads"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/models/downloads"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_get_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/models/downloads/{task_id}"
|
||||
},
|
||||
{
|
||||
"handler": "model_download_cancel_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/models/downloads/{task_id}/cancel"
|
||||
},
|
||||
{
|
||||
"handler": "model_import_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/models/import"
|
||||
},
|
||||
{
|
||||
"handler": "model_installations_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/models/installations"
|
||||
},
|
||||
{
|
||||
"handler": "secrets_status_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/secrets/status"
|
||||
},
|
||||
{
|
||||
"handler": "secrets_put_handler",
|
||||
"method": "PUT",
|
||||
"path": "/openclaw/secrets"
|
||||
},
|
||||
{
|
||||
"handler": "events_stream_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/events/stream"
|
||||
},
|
||||
{
|
||||
"handler": "events_poll_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/events"
|
||||
},
|
||||
{
|
||||
"handler": "secrets_delete_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/openclaw/secrets/{provider}"
|
||||
},
|
||||
{
|
||||
"handler": "security_doctor_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/security/doctor"
|
||||
},
|
||||
{
|
||||
"handler": "tools_list_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/tools"
|
||||
},
|
||||
{
|
||||
"handler": "tools_run_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/tools/{name}/run"
|
||||
},
|
||||
{
|
||||
"handler": "create_sweep_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/lab/sweep"
|
||||
},
|
||||
{
|
||||
"handler": "create_compare_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/lab/compare"
|
||||
},
|
||||
{
|
||||
"handler": "list_experiments_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/lab/experiments"
|
||||
},
|
||||
{
|
||||
"handler": "get_experiment_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/lab/experiments/{exp_id}"
|
||||
},
|
||||
{
|
||||
"handler": "update_experiment_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/lab/experiments/{exp_id}/runs/{run_id}"
|
||||
},
|
||||
{
|
||||
"handler": "select_apply_winner_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/lab/experiments/{exp_id}/winner"
|
||||
}
|
||||
],
|
||||
"packs:/moltbot": [
|
||||
{
|
||||
"handler": "list_packs_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/packs"
|
||||
},
|
||||
{
|
||||
"handler": "import_pack_handler",
|
||||
"method": "POST",
|
||||
"path": "/moltbot/packs/import"
|
||||
},
|
||||
{
|
||||
"handler": "export_pack_handler",
|
||||
"method": "GET",
|
||||
"path": "/moltbot/packs/export/{name}/{version}"
|
||||
},
|
||||
{
|
||||
"handler": "delete_pack_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/moltbot/packs/{name}/{version}"
|
||||
}
|
||||
],
|
||||
"packs:/openclaw": [
|
||||
{
|
||||
"handler": "list_packs_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/packs"
|
||||
},
|
||||
{
|
||||
"handler": "import_pack_handler",
|
||||
"method": "POST",
|
||||
"path": "/openclaw/packs/import"
|
||||
},
|
||||
{
|
||||
"handler": "export_pack_handler",
|
||||
"method": "GET",
|
||||
"path": "/openclaw/packs/export/{name}/{version}"
|
||||
},
|
||||
{
|
||||
"handler": "delete_pack_handler",
|
||||
"method": "DELETE",
|
||||
"path": "/openclaw/packs/{name}/{version}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"feature_conditions": {
|
||||
"assist": "assist is truthy",
|
||||
"bridge": "server.app exists and BRIDGE module is enabled",
|
||||
"connector_installations": "list handler is truthy",
|
||||
"packs": "optional pack imports succeed"
|
||||
},
|
||||
"legacy_rule": "moltbot handlers retain telemetry and deprecation headers",
|
||||
"openapi_sha256": "7997883a91ca7b3e3058d3c299512947ca634d92ddd1aa4c06f0d33539325f33",
|
||||
"registration_order": [
|
||||
"startup_profile_gate",
|
||||
"core:/openclaw",
|
||||
"core:/moltbot",
|
||||
"assist:/openclaw",
|
||||
"assist:/moltbot",
|
||||
"connector_installations:/openclaw",
|
||||
"connector_installations:/moltbot",
|
||||
"bridge",
|
||||
"mae_posture_gate",
|
||||
"packs:/openclaw",
|
||||
"packs:/moltbot"
|
||||
],
|
||||
"schema_version": 1
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"review": {
|
||||
"owner": "architecture-maintainers",
|
||||
"reviewed_at": "2026-07-31",
|
||||
"next_review_by": "2026-10-31",
|
||||
"static_analysis_policy_schema": 1
|
||||
},
|
||||
"tracked_roots": [
|
||||
"__init__.py",
|
||||
"config.py",
|
||||
"api",
|
||||
"connector",
|
||||
"models",
|
||||
"nodes",
|
||||
"services",
|
||||
"scripts"
|
||||
],
|
||||
"domains": {
|
||||
"api": [
|
||||
"api/__init__.py",
|
||||
"api/approvals.py",
|
||||
"api/assist.py",
|
||||
"api/bridge.py",
|
||||
"api/capabilities.py",
|
||||
"api/checkpoints_handler.py",
|
||||
"api/config.py",
|
||||
"api/config_llm_handlers.py",
|
||||
"api/config_model_handlers.py",
|
||||
"api/config_projection_handlers.py",
|
||||
"api/connector_contracts.py",
|
||||
"api/errors.py",
|
||||
"api/events.py",
|
||||
"api/model_manager.py",
|
||||
"api/packs.py",
|
||||
"api/pnginfo.py",
|
||||
"api/preflight_handler.py",
|
||||
"api/presets.py",
|
||||
"api/remote_admin.py",
|
||||
"api/rewrite_recipes.py",
|
||||
"api/route_handlers.py",
|
||||
"api/route_orchestration.py",
|
||||
"api/route_registrars.py",
|
||||
"api/routes.py",
|
||||
"api/schedules.py",
|
||||
"api/secrets.py",
|
||||
"api/security_doctor.py",
|
||||
"api/templates.py",
|
||||
"api/tools.py",
|
||||
"api/triggers.py",
|
||||
"api/webhook.py",
|
||||
"api/webhook_submit.py",
|
||||
"api/webhook_validate.py"
|
||||
],
|
||||
"connector": [
|
||||
"connector/__init__.py",
|
||||
"connector/__main__.py",
|
||||
"connector/channels/__init__.py",
|
||||
"connector/channels/kakaotalk.py",
|
||||
"connector/command_firewall.py",
|
||||
"connector/config.py",
|
||||
"connector/contract.py",
|
||||
"connector/jobs_summary.py",
|
||||
"connector/llm_client.py",
|
||||
"connector/media_response.py",
|
||||
"connector/media_store.py",
|
||||
"connector/openclaw_client.py",
|
||||
"connector/platforms/__init__.py",
|
||||
"connector/platforms/discord_gateway.py",
|
||||
"connector/platforms/feishu_delivery_handlers.py",
|
||||
"connector/platforms/feishu_ingress_handlers.py",
|
||||
"connector/platforms/feishu_installation_handlers.py",
|
||||
"connector/platforms/feishu_installation_manager.py",
|
||||
"connector/platforms/feishu_long_connection.py",
|
||||
"connector/platforms/feishu_webhook.py",
|
||||
"connector/platforms/kakao_webhook.py",
|
||||
"connector/platforms/line_webhook.py",
|
||||
"connector/platforms/slack_delivery_handlers.py",
|
||||
"connector/platforms/slack_ingress_handlers.py",
|
||||
"connector/platforms/slack_installation_handlers.py",
|
||||
"connector/platforms/slack_installation_manager.py",
|
||||
"connector/platforms/slack_socket_mode.py",
|
||||
"connector/platforms/slack_webhook.py",
|
||||
"connector/platforms/telegram_polling.py",
|
||||
"connector/platforms/wechat_webhook.py",
|
||||
"connector/platforms/whatsapp_webhook.py",
|
||||
"connector/prompts.py",
|
||||
"connector/rate_limiter.py",
|
||||
"connector/reply_visibility.py",
|
||||
"connector/results_poller.py",
|
||||
"connector/router.py",
|
||||
"connector/router_admin_handlers.py",
|
||||
"connector/router_chat_handlers.py",
|
||||
"connector/router_dispatch.py",
|
||||
"connector/router_execution_handlers.py",
|
||||
"connector/security_profile.py",
|
||||
"connector/semantic_guard.py",
|
||||
"connector/state.py",
|
||||
"connector/transport_contract.py"
|
||||
],
|
||||
"models": [
|
||||
"models/__init__.py",
|
||||
"models/schemas.py"
|
||||
],
|
||||
"nodes": [
|
||||
"nodes/__init__.py",
|
||||
"nodes/batch_variants.py",
|
||||
"nodes/image_to_prompt.py",
|
||||
"nodes/portability_contract.py",
|
||||
"nodes/prompt_planner.py",
|
||||
"nodes/prompt_refiner.py"
|
||||
],
|
||||
"root": [
|
||||
"__init__.py",
|
||||
"config.py"
|
||||
],
|
||||
"scripts": [
|
||||
"scripts/check_deployment_profile.py",
|
||||
"scripts/check_openapi_sync.py",
|
||||
"scripts/check_supply_chain_hardening.py",
|
||||
"scripts/compatibility_matrix_refresh.py",
|
||||
"scripts/contract_digest.py",
|
||||
"scripts/devtools/debug_s35_import.py",
|
||||
"scripts/devtools/verify_s30_doctor.py",
|
||||
"scripts/generate_openapi_spec.py",
|
||||
"scripts/generate_provenance.py",
|
||||
"scripts/high_risk_acceptance.py",
|
||||
"scripts/lint_implementation_record.py",
|
||||
"scripts/openclaw_smoke_import.py",
|
||||
"scripts/operator_doctor.py",
|
||||
"scripts/precommit_black_single.py",
|
||||
"scripts/precommit_block_sensitive_files.py",
|
||||
"scripts/preflight_check.py",
|
||||
"scripts/quality_governance_common.py",
|
||||
"scripts/regenerate_openapi_if_needed.py",
|
||||
"scripts/registry_publish_guard.py",
|
||||
"scripts/report_coverage_governance.py",
|
||||
"scripts/run_adversarial_gate.py",
|
||||
"scripts/run_backend_coverage.py",
|
||||
"scripts/run_crypto_lifecycle_drills.py",
|
||||
"scripts/run_mutation_test.py",
|
||||
"scripts/run_unittests.py",
|
||||
"scripts/start_sidecar.py",
|
||||
"scripts/verify_api_config_contract.py",
|
||||
"scripts/verify_api_route_contract.py",
|
||||
"scripts/verify_audit_chain.py",
|
||||
"scripts/verify_connector_router_contract.py",
|
||||
"scripts/verify_exception_boundary_policy.py",
|
||||
"scripts/verify_platform_adapter_contract.py",
|
||||
"scripts/verify_production_dependencies.py",
|
||||
"scripts/verify_provenance.py",
|
||||
"scripts/verify_quality_governance.py",
|
||||
"scripts/verify_static_analysis_policy.py",
|
||||
"scripts/verify_test_debt_governance.py"
|
||||
],
|
||||
"services": [
|
||||
"services/__init__.py",
|
||||
"services/access_control.py",
|
||||
"services/aiohttp_compat.py",
|
||||
"services/approvals/__init__.py",
|
||||
"services/approvals/models.py",
|
||||
"services/approvals/service.py",
|
||||
"services/approvals/storage.py",
|
||||
"services/async_utils.py",
|
||||
"services/audit.py",
|
||||
"services/audit_events.py",
|
||||
"services/audit_pipeline.py",
|
||||
"services/automation_composer.py",
|
||||
"services/bridge_handshake.py",
|
||||
"services/bridge_token_lifecycle.py",
|
||||
"services/bootstrap/__init__.py",
|
||||
"services/bootstrap/lifecycle.py",
|
||||
"services/bootstrap/registration.py",
|
||||
"services/cache/__init__.py",
|
||||
"services/cache/ttl_cache.py",
|
||||
"services/callback_delivery.py",
|
||||
"services/capabilities.py",
|
||||
"services/chatops/__init__.py",
|
||||
"services/chatops/network_errors.py",
|
||||
"services/chatops/retry.py",
|
||||
"services/chatops/session_scope.py",
|
||||
"services/chatops/targets.py",
|
||||
"services/chatops/transport_contract.py",
|
||||
"services/chatops/webhook_adapter.py",
|
||||
"services/checkpoints.py",
|
||||
"services/comfyui_history.py",
|
||||
"services/compatibility_matrix_governance.py",
|
||||
"services/config_layers.py",
|
||||
"services/connector_allowlist_posture.py",
|
||||
"services/connector_callback_contract.py",
|
||||
"services/connector_extraction_contract.py",
|
||||
"services/connector_installation_registry.py",
|
||||
"services/connector_replay_lifecycle.py",
|
||||
"services/constrained_transforms.py",
|
||||
"services/control_plane.py",
|
||||
"services/control_plane_adapter.py",
|
||||
"services/crypto_lifecycle_drills.py",
|
||||
"services/csrf_protection.py",
|
||||
"services/delivery/__init__.py",
|
||||
"services/delivery/http_callback.py",
|
||||
"services/delivery/router.py",
|
||||
"services/deployment_profile.py",
|
||||
"services/diagnostics_flags.py",
|
||||
"services/effective_config.py",
|
||||
"services/effective_security_posture.py",
|
||||
"services/endpoint_manifest.py",
|
||||
"services/execution_budgets.py",
|
||||
"services/failover.py",
|
||||
"services/idempotency_store.py",
|
||||
"services/image_utils.py",
|
||||
"services/import_fallback.py",
|
||||
"services/integrity.py",
|
||||
"services/internal_content.py",
|
||||
"services/job_events.py",
|
||||
"services/jobs_read_model.py",
|
||||
"services/jobs_security.py",
|
||||
"services/legacy_compat.py",
|
||||
"services/llm_client.py",
|
||||
"services/llm_model_list.py",
|
||||
"services/llm_output.py",
|
||||
"services/log_tail.py",
|
||||
"services/management_query.py",
|
||||
"services/metrics.py",
|
||||
"services/model_manager.py",
|
||||
"services/model_manager_catalog.py",
|
||||
"services/model_manager_tasks.py",
|
||||
"services/model_manager_transfer.py",
|
||||
"services/modules.py",
|
||||
"services/observability/backpressure.py",
|
||||
"services/openapi_generation.py",
|
||||
"services/operator_doctor.py",
|
||||
"services/operator_guidance.py",
|
||||
"services/package_hygiene.py",
|
||||
"services/packs/pack_archive.py",
|
||||
"services/packs/pack_manifest.py",
|
||||
"services/packs/pack_registry.py",
|
||||
"services/packs/pack_types.py",
|
||||
"services/parameter_lab.py",
|
||||
"services/parameter_lab_policy.py",
|
||||
"services/parameter_lab_queue_receipt.py",
|
||||
"services/paths.py",
|
||||
"services/permission_posture.py",
|
||||
"services/planner.py",
|
||||
"services/planner_registry.py",
|
||||
"services/plugins/__init__.py",
|
||||
"services/plugins/async_bridge.py",
|
||||
"services/plugins/builtin/__init__.py",
|
||||
"services/plugins/builtin/audit_log.py",
|
||||
"services/plugins/builtin/model_alias.py",
|
||||
"services/plugins/builtin/params_clamp.py",
|
||||
"services/plugins/contract.py",
|
||||
"services/plugins/manager.py",
|
||||
"services/pnginfo.py",
|
||||
"services/policy_posture.py",
|
||||
"services/posture/__init__.py",
|
||||
"services/posture/effective.py",
|
||||
"services/preflight.py",
|
||||
"services/presets/__init__.py",
|
||||
"services/presets/models.py",
|
||||
"services/presets/storage.py",
|
||||
"services/product_boundary.py",
|
||||
"services/provider_errors.py",
|
||||
"services/providers/__init__.py",
|
||||
"services/providers/anthropic.py",
|
||||
"services/providers/catalog.py",
|
||||
"services/providers/keys.py",
|
||||
"services/providers/openai_compat.py",
|
||||
"services/queue_submit.py",
|
||||
"services/rate_limit.py",
|
||||
"services/reasoning_redaction.py",
|
||||
"services/redaction.py",
|
||||
"services/refiner.py",
|
||||
"services/registry.py",
|
||||
"services/registry_quarantine.py",
|
||||
"services/request_contracts.py",
|
||||
"services/request_ip.py",
|
||||
"services/retry_after.py",
|
||||
"services/retry_partition.py",
|
||||
"services/rewrite_recipes.py",
|
||||
"services/route_bootstrap.py",
|
||||
"services/route_bootstrap_contract.py",
|
||||
"services/runtime_config.py",
|
||||
"services/runtime_config_policy.py",
|
||||
"services/runtime_config_projection.py",
|
||||
"services/runtime_config_store.py",
|
||||
"services/runtime_dependency_hygiene.py",
|
||||
"services/runtime_guardrails.py",
|
||||
"services/runtime_lifecycle.py",
|
||||
"services/runtime_profile.py",
|
||||
"services/safe_io.py",
|
||||
"services/scheduler/__init__.py",
|
||||
"services/scheduler/delivery_contract.py",
|
||||
"services/scheduler/history.py",
|
||||
"services/scheduler/models.py",
|
||||
"services/scheduler/runner.py",
|
||||
"services/scheduler/storage.py",
|
||||
"services/schema_sanitizer.py",
|
||||
"services/secret_providers.py",
|
||||
"services/secret_store.py",
|
||||
"services/secrets_encryption.py",
|
||||
"services/security_advisories.py",
|
||||
"services/security_doctor.py",
|
||||
"services/security_doctor_checks.py",
|
||||
"services/security_doctor_connector_checks.py",
|
||||
"services/security_doctor_endpoint_checks.py",
|
||||
"services/security_doctor_remediation.py",
|
||||
"services/security_doctor_report.py",
|
||||
"services/security_doctor_runner.py",
|
||||
"services/security_doctor_runtime_checks.py",
|
||||
"services/security_gate.py",
|
||||
"services/security_invariants.py",
|
||||
"services/security_telemetry.py",
|
||||
"services/settings_schema.py",
|
||||
"services/sidecar/__init__.py",
|
||||
"services/sidecar/auth.py",
|
||||
"services/sidecar/bridge_client.py",
|
||||
"services/sidecar/bridge_contract.py",
|
||||
"services/sidecar/runtime.py",
|
||||
"services/sidecar_secret_refs.py",
|
||||
"services/startup_lifecycle.py",
|
||||
"services/startup_profile_gate.py",
|
||||
"services/state_dir.py",
|
||||
"services/structured_logging.py",
|
||||
"services/surface_guard.py",
|
||||
"services/templates.py",
|
||||
"services/tenant_context.py",
|
||||
"services/threat_intel_gate.py",
|
||||
"services/threat_intel_provider.py",
|
||||
"services/tool_calling.py",
|
||||
"services/tool_runner.py",
|
||||
"services/trace.py",
|
||||
"services/trace_store.py",
|
||||
"services/transform_common.py",
|
||||
"services/transform_runner.py",
|
||||
"services/transform_worker.py",
|
||||
"services/webhook_auth.py",
|
||||
"services/webhook_mapping.py",
|
||||
"services/workflow_portability.py"
|
||||
]
|
||||
},
|
||||
"allowed_dependencies": {
|
||||
"api": [
|
||||
"api",
|
||||
"models",
|
||||
"root",
|
||||
"services"
|
||||
],
|
||||
"connector": [
|
||||
"connector",
|
||||
"services"
|
||||
],
|
||||
"models": [
|
||||
"models"
|
||||
],
|
||||
"nodes": [
|
||||
"models",
|
||||
"nodes",
|
||||
"services"
|
||||
],
|
||||
"root": [
|
||||
"nodes",
|
||||
"root",
|
||||
"services"
|
||||
],
|
||||
"scripts": [
|
||||
"api",
|
||||
"connector",
|
||||
"models",
|
||||
"nodes",
|
||||
"root",
|
||||
"scripts",
|
||||
"services"
|
||||
],
|
||||
"services": [
|
||||
"models",
|
||||
"root",
|
||||
"services"
|
||||
]
|
||||
},
|
||||
"compatibility_exceptions": [
|
||||
{
|
||||
"importer": "models.schemas",
|
||||
"imported": "services.request_contracts",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.connector_callback_contract",
|
||||
"imported": "connector.config",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.connector_callback_contract",
|
||||
"imported": "connector.security_profile",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.connector_callback_contract",
|
||||
"imported": "connector.transport_contract",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.preflight",
|
||||
"imported": "nodes",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.queue_submit",
|
||||
"imported": "api.errors",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.sidecar.runtime",
|
||||
"imported": "connector.config",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.sidecar.runtime",
|
||||
"imported": "connector.openclaw_client",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
},
|
||||
{
|
||||
"importer": "services.workflow_portability",
|
||||
"imported": "nodes.portability_contract",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing boundary retained for compatibility while domain ownership is reduced.",
|
||||
"review_condition": "Remove when the importer no longer requires the higher-level domain."
|
||||
}
|
||||
],
|
||||
"facade_contracts": [
|
||||
{
|
||||
"facade": "services.startup_lifecycle",
|
||||
"implementation": "services.bootstrap.lifecycle",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Preserve accepted lifecycle imports, singleton identity, and patch seams while implementation ownership lives in the bootstrap package.",
|
||||
"review_condition": "Review when the legacy startup_lifecycle import path can be removed without compatibility impact."
|
||||
},
|
||||
{
|
||||
"facade": "services.route_bootstrap",
|
||||
"implementation": "services.bootstrap.registration",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Preserve accepted route-bootstrap imports and patch seams while implementation ownership lives in the bootstrap package.",
|
||||
"review_condition": "Review when the legacy route_bootstrap import path can be removed without compatibility impact."
|
||||
},
|
||||
{
|
||||
"facade": "services.effective_security_posture",
|
||||
"implementation": "services.posture.effective",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Preserve accepted posture imports and installed-snapshot identity while implementation ownership lives in the posture package.",
|
||||
"review_condition": "Review when the legacy effective_security_posture import path can be removed without compatibility impact."
|
||||
}
|
||||
],
|
||||
"accepted_cycles": [
|
||||
{
|
||||
"modules": [
|
||||
"config",
|
||||
"services.effective_config",
|
||||
"services.runtime_config",
|
||||
"services.runtime_config_policy",
|
||||
"services.safe_io"
|
||||
],
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing runtime configuration and safe-I/O bootstrap cycle.",
|
||||
"review_condition": "Remove when the cycle is decomposed into one-way contracts."
|
||||
},
|
||||
{
|
||||
"modules": [
|
||||
"services.access_control",
|
||||
"services.audit_events",
|
||||
"services.reasoning_redaction",
|
||||
"services.security_telemetry"
|
||||
],
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Existing security telemetry, audit, access, and redaction cycle.",
|
||||
"review_condition": "Remove when the cycle is decomposed into one-way contracts."
|
||||
}
|
||||
],
|
||||
"dynamic_imports": [
|
||||
{
|
||||
"path": "api/routes.py",
|
||||
"scope": "check_dependency",
|
||||
"callee": "__import__",
|
||||
"target_kind": "expression",
|
||||
"target": "module_name",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Optional dependency availability probe.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "scripts/openclaw_smoke_import.py",
|
||||
"scope": "main",
|
||||
"callee": "__import__",
|
||||
"target_kind": "expression",
|
||||
"target": "candidate",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Package/top-level smoke import candidate.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "scripts/openclaw_smoke_import.py",
|
||||
"scope": "test_import",
|
||||
"callee": "__import__",
|
||||
"target_kind": "expression",
|
||||
"target": "module_name",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Smoke-test module import boundary.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "services/import_fallback.py",
|
||||
"scope": "import_module_dual",
|
||||
"callee": "importlib.import_module",
|
||||
"target_kind": "expression",
|
||||
"target": "absolute_module",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Top-level fallback half of dual import compatibility.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "services/import_fallback.py",
|
||||
"scope": "import_module_dual",
|
||||
"callee": "importlib.import_module",
|
||||
"target_kind": "expression",
|
||||
"target": "relative_module",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Package-relative half of dual import compatibility.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "services/jobs_read_model.py",
|
||||
"scope": "_resolve_get_all_jobs",
|
||||
"callee": "importlib.import_module",
|
||||
"target_kind": "literal",
|
||||
"target": "comfy_execution.jobs",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Optional ComfyUI jobs host capability.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "services/operator_doctor.py",
|
||||
"scope": "check_core_imports",
|
||||
"callee": "importlib.import_module",
|
||||
"target_kind": "expression",
|
||||
"target": "mod_name",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Operator-doctor import health probe.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
},
|
||||
{
|
||||
"path": "services/operator_doctor.py",
|
||||
"scope": "check_pre_commit",
|
||||
"callee": "importlib.import_module",
|
||||
"target_kind": "literal",
|
||||
"target": "pre_commit",
|
||||
"owner": "architecture-maintainers",
|
||||
"rationale": "Optional quality-tool availability probe.",
|
||||
"review_condition": "Remove or update when the dynamic loading boundary changes."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"command_table": [
|
||||
{
|
||||
"aliases": [
|
||||
"/status",
|
||||
"status"
|
||||
],
|
||||
"class": "PUBLIC",
|
||||
"handler": "_handle_status"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/help",
|
||||
"help",
|
||||
"/start"
|
||||
],
|
||||
"class": "PUBLIC",
|
||||
"handler": "_handle_help"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/run",
|
||||
"run"
|
||||
],
|
||||
"class": "RUN",
|
||||
"handler": "_handle_run"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/interrupt",
|
||||
"interrupt",
|
||||
"/cancel",
|
||||
"cancel",
|
||||
"/stop"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_interrupt"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/approvals",
|
||||
"approvals"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_approvals_list"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/approve",
|
||||
"approve"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_approve"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/reject",
|
||||
"reject"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_reject"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/schedules",
|
||||
"schedules"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_schedules_list"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/schedule",
|
||||
"schedule"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_schedule_subcommand"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/history",
|
||||
"history"
|
||||
],
|
||||
"class": "PUBLIC",
|
||||
"handler": "_handle_history"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/trace",
|
||||
"trace"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_trace"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/jobs",
|
||||
"jobs",
|
||||
"queue"
|
||||
],
|
||||
"class": "ADMIN",
|
||||
"handler": "_handle_jobs"
|
||||
},
|
||||
{
|
||||
"aliases": [
|
||||
"/chat",
|
||||
"chat"
|
||||
],
|
||||
"class": "PUBLIC",
|
||||
"handler": "_handle_chat"
|
||||
}
|
||||
],
|
||||
"constructor_signature": "(config: connector.config.ConnectorConfig, client: connector.openclaw_client.OpenClawClient, poller: 'ResultsPoller' = None)",
|
||||
"facade_signatures": {
|
||||
"__init__": "(self, config: connector.config.ConnectorConfig, client: connector.openclaw_client.OpenClawClient, poller: 'ResultsPoller' = None)",
|
||||
"_chat_general": "(self, llm: connector.llm_client.LLMClient, message: str, trust_level: str) -> connector.contract.CommandResponse",
|
||||
"_chat_run": "(self, llm: connector.llm_client.LLMClient, request: str, trust_level: str) -> connector.contract.CommandResponse",
|
||||
"_chat_status": "(self, llm: connector.llm_client.LLMClient) -> connector.contract.CommandResponse",
|
||||
"_chat_template": "(self, llm: connector.llm_client.LLMClient, request: str) -> connector.contract.CommandResponse",
|
||||
"_check_command_authz": "(self, cmd: str, req: connector.contract.CommandRequest, default_class: connector.config.CommandClass) -> Optional[connector.contract.CommandResponse]",
|
||||
"_delivery_context": "(self, req: connector.contract.CommandRequest) -> Dict[str, Any]",
|
||||
"_get_template_meta": "(self, template_id: str) -> Dict[str, Any]",
|
||||
"_handle_approvals_list": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_approve": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_chat": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_help": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_history": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_interrupt": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_jobs": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_reject": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_run": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_schedule_subcommand": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_schedules_list": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_status": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_handle_trace": "(self, req: connector.contract.CommandRequest, args: List[str]) -> connector.contract.CommandResponse",
|
||||
"_is_admin": "(self, user_id: str) -> bool",
|
||||
"_is_trusted": "(self, req: connector.contract.CommandRequest) -> bool",
|
||||
"_jobs_cancel_unsupported": "(res: Dict[str, Any]) -> bool",
|
||||
"_parse_stop_targets": "(args: List[str]) -> List[str]",
|
||||
"_policy_kv": "(contract: Dict[str, Any]) -> str",
|
||||
"_require_admin_token_configured": "(self) -> Optional[connector.contract.CommandResponse]",
|
||||
"_resolve_prompt_key": "(self, template_id: str) -> str",
|
||||
"handle": "(self, req: connector.contract.CommandRequest) -> connector.contract.CommandResponse"
|
||||
},
|
||||
"instance_ownership": [
|
||||
"config",
|
||||
"client",
|
||||
"poller",
|
||||
"state",
|
||||
"_template_meta_cache",
|
||||
"_rate_limiter",
|
||||
"semantic_guard",
|
||||
"command_firewall"
|
||||
],
|
||||
"response_matrix_owners": [
|
||||
"tests.connector.test_router_hotspot_r181",
|
||||
"tests.connector.test_r214_jobs_command",
|
||||
"tests.connector.test_router_admin",
|
||||
"tests.connector.test_router_command_authz_r80",
|
||||
"tests.connector.test_security",
|
||||
"tests.connector.test_chat",
|
||||
"tests.connector.test_chat_integration",
|
||||
"tests.connector.test_media_delivery",
|
||||
"tests.chat_connector.test_router_phase2",
|
||||
"tests.chat_connector.test_router_phase3"
|
||||
],
|
||||
"schema_version": 1,
|
||||
"upstream_contract_digests": {
|
||||
"api_config_contract_r221.json": "a0a4aec0ba68692bf8bfac411fb951f95589006ba3d5d6ad463569d24dfc1245",
|
||||
"api_route_contract_r220.json": "947979f978f3541dce6b005c38255177322234a24b47ee0dd22a49e234eeed27"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"current_stage": "ratchet-45",
|
||||
"current_stage": "ratchet-55",
|
||||
"stages": [
|
||||
{
|
||||
"id": "baseline-35",
|
||||
@@ -76,7 +76,12 @@
|
||||
"paths": [
|
||||
"connector/config.py",
|
||||
"connector/router.py"
|
||||
]
|
||||
],
|
||||
"ratchet55_readiness": {
|
||||
"targeted_regression_suite": "tests/test_r177_connector_config_env.py; tests/connector/test_config_r80.py; tests/connector/test_router_hotspot_r181.py; tests/test_r222_connector_router_decomposition.py",
|
||||
"ownership_status": "targeted-regression-owned",
|
||||
"readiness_notes": "Environment precedence, connector policy, router boundaries, and decomposed command ownership remain covered at the promoted floor."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "config_bootstrap",
|
||||
@@ -84,7 +89,12 @@
|
||||
"config.py",
|
||||
"services/runtime_config.py",
|
||||
"services/effective_config.py"
|
||||
]
|
||||
],
|
||||
"ratchet55_readiness": {
|
||||
"targeted_regression_suite": "tests/test_r176_import_safe_config.py; tests/test_r182_effective_config_hotspot.py; tests/test_runtime_config.py; tests/test_effective_config.py",
|
||||
"ownership_status": "targeted-regression-owned",
|
||||
"readiness_notes": "Import-safe bootstrap, effective configuration, runtime configuration, and precedence behavior have explicit regression ownership."
|
||||
}
|
||||
}
|
||||
],
|
||||
"exceptions": []
|
||||
|
||||
@@ -30,6 +30,116 @@
|
||||
"config_bootstrap": 63.2
|
||||
},
|
||||
"artifact_reference": "2026-04-20 Windows backend coverage review cycle 2 via scripts/run_backend_coverage.py (.tmp/coverage/r181_r182_cycle2.json)"
|
||||
},
|
||||
{
|
||||
"cycle_id": "v0.9.0-to-v0.9.5-ratchet45",
|
||||
"stage_id": "ratchet-45",
|
||||
"reviewed_at": "2026-07-11",
|
||||
"overall_percent_covered": 69.78,
|
||||
"reviewed_hotspot_families": [
|
||||
"safe_io",
|
||||
"security_boundary",
|
||||
"connector_config",
|
||||
"config_bootstrap"
|
||||
],
|
||||
"hotspot_percent_covered": {
|
||||
"safe_io": 82.95,
|
||||
"security_boundary": 63.33,
|
||||
"connector_config": 65.03,
|
||||
"config_bootstrap": 68.77
|
||||
},
|
||||
"owned_regression_suites": {
|
||||
"safe_io": [
|
||||
"tests/test_safe_io.py",
|
||||
"tests/test_callback_url_policy.py",
|
||||
"tests/test_s36s37r79_egress_hardening.py",
|
||||
"tests/test_r185_hotspot_regression_ownership.py"
|
||||
],
|
||||
"security_boundary": [
|
||||
"tests/security/test_startup_gate.py",
|
||||
"tests/security/test_r99_sensitive_contract.py",
|
||||
"tests/security/test_rbac.py",
|
||||
"tests/test_s57_secrets_encryption.py",
|
||||
"tests/test_r185_hotspot_regression_ownership.py"
|
||||
],
|
||||
"connector_config": [
|
||||
"tests/test_r177_connector_config_env.py",
|
||||
"tests/connector/test_config_r80.py",
|
||||
"tests/connector/test_router_hotspot_r181.py",
|
||||
"tests/test_r222_connector_router_decomposition.py"
|
||||
],
|
||||
"config_bootstrap": [
|
||||
"tests/test_r176_import_safe_config.py",
|
||||
"tests/test_r182_effective_config_hotspot.py",
|
||||
"tests/test_runtime_config.py",
|
||||
"tests/test_effective_config.py"
|
||||
]
|
||||
},
|
||||
"release_cycle": {
|
||||
"start_tag": "v0.9.0",
|
||||
"start_commit": "9285f44766df5a24e2296a98d1cb078077d311df",
|
||||
"end_tag": "v0.9.5",
|
||||
"end_commit": "18a53416978dda975ece6db7b1369985ced19fe0"
|
||||
},
|
||||
"reviewed_commit": "18a53416978dda975ece6db7b1369985ced19fe0",
|
||||
"coverage_command": "python scripts/run_backend_coverage.py --start-dir tests --pattern test_*.py --enforce-skip-policy tests/skip_policy.json",
|
||||
"artifact_reference": "v0.9.5 full-suite coverage JSON reconstructed from immutable release commit",
|
||||
"artifact_sha256": "9f66e986b1733a91c483e8c933917653af9de87d9d59ccb3b37459d9fb5cf34d"
|
||||
},
|
||||
{
|
||||
"cycle_id": "v0.9.5-to-v1.0.0-ratchet45",
|
||||
"stage_id": "ratchet-45",
|
||||
"reviewed_at": "2026-07-11",
|
||||
"overall_percent_covered": 70.28,
|
||||
"reviewed_hotspot_families": [
|
||||
"safe_io",
|
||||
"security_boundary",
|
||||
"connector_config",
|
||||
"config_bootstrap"
|
||||
],
|
||||
"hotspot_percent_covered": {
|
||||
"safe_io": 82.95,
|
||||
"security_boundary": 64.5,
|
||||
"connector_config": 65.54,
|
||||
"config_bootstrap": 68.77
|
||||
},
|
||||
"owned_regression_suites": {
|
||||
"safe_io": [
|
||||
"tests/test_safe_io.py",
|
||||
"tests/test_callback_url_policy.py",
|
||||
"tests/test_s36s37r79_egress_hardening.py",
|
||||
"tests/test_r185_hotspot_regression_ownership.py"
|
||||
],
|
||||
"security_boundary": [
|
||||
"tests/security/test_startup_gate.py",
|
||||
"tests/security/test_r99_sensitive_contract.py",
|
||||
"tests/security/test_rbac.py",
|
||||
"tests/test_s57_secrets_encryption.py",
|
||||
"tests/test_r185_hotspot_regression_ownership.py"
|
||||
],
|
||||
"connector_config": [
|
||||
"tests/test_r177_connector_config_env.py",
|
||||
"tests/connector/test_config_r80.py",
|
||||
"tests/connector/test_router_hotspot_r181.py",
|
||||
"tests/test_r222_connector_router_decomposition.py"
|
||||
],
|
||||
"config_bootstrap": [
|
||||
"tests/test_r176_import_safe_config.py",
|
||||
"tests/test_r182_effective_config_hotspot.py",
|
||||
"tests/test_runtime_config.py",
|
||||
"tests/test_effective_config.py"
|
||||
]
|
||||
},
|
||||
"release_cycle": {
|
||||
"start_tag": "v0.9.5",
|
||||
"start_commit": "18a53416978dda975ece6db7b1369985ced19fe0",
|
||||
"end_tag": "v1.0.0",
|
||||
"end_commit": "0f23b04a324f1fc150bc10fc97a4a5846f44490e"
|
||||
},
|
||||
"reviewed_commit": "0f23b04a324f1fc150bc10fc97a4a5846f44490e",
|
||||
"coverage_command": "python scripts/run_backend_coverage.py --start-dir tests --pattern test_*.py --enforce-skip-policy tests/skip_policy.json",
|
||||
"artifact_reference": "v1.0.0 full-suite coverage JSON reconstructed from immutable release commit",
|
||||
"artifact_sha256": "5f693f5cead750722f94e7d6dd0a71d1b5b721f07fb6b61c12edee260b445599"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clickTab,
|
||||
mockCompatApprovalsList,
|
||||
@@ -17,6 +19,17 @@ const pendingApproval = {
|
||||
inputs: { prompt: 'desktop host parity' },
|
||||
};
|
||||
|
||||
async function captureParityEvidence(page, testInfo, name) {
|
||||
const screenshot = await page.screenshot();
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
const evidenceDir = path.resolve(process.cwd(), '.tmp', 'desktop-host-parity-evidence');
|
||||
await mkdir(evidenceDir, { recursive: true });
|
||||
await writeFile(path.join(evidenceDir, `${name}.png`), screenshot);
|
||||
}
|
||||
|
||||
test.describe('Desktop host parity lane', () => {
|
||||
test('keeps standalone sidebar evidence separate from desktop host evidence', async ({ page }) => {
|
||||
await mockComfyUiCore(page, { hostSurface: 'standalone_frontend' });
|
||||
@@ -26,10 +39,16 @@ test.describe('Desktop host parity lane', () => {
|
||||
const host = page.locator('#sidebar-tab-comfyui-openclaw');
|
||||
await expect(host).toHaveAttribute('data-openclaw-host-surface', 'standalone_frontend');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-host', 'false');
|
||||
await expect(host).toHaveAttribute('data-openclaw-reference-frontend', '1.48.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-reference-frontend', '1.49.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-current-desktop-version', '1.0.32-rc.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-current-desktop-generation', 'managed_install');
|
||||
await expect(host).toHaveAttribute(
|
||||
'data-openclaw-current-desktop-hosted-version-mode',
|
||||
'installation_specific',
|
||||
);
|
||||
});
|
||||
|
||||
test('boots the sidebar under desktop host signals and keeps approvals interactive', async ({ page }) => {
|
||||
test('boots the legacy sidebar under desktop host signals and keeps approvals interactive', async ({ page }, testInfo) => {
|
||||
await mockComfyUiCore(page, { hostSurface: 'desktop' });
|
||||
await mockCompatApprovalsList(page, [pendingApproval]);
|
||||
await page.goto('test-harness.html');
|
||||
@@ -38,7 +57,16 @@ test.describe('Desktop host parity lane', () => {
|
||||
const host = page.locator('#sidebar-tab-comfyui-openclaw');
|
||||
await expect(host).toHaveAttribute('data-openclaw-host-surface', 'desktop');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-host', 'true');
|
||||
await expect(host).toHaveAttribute('data-openclaw-reference-frontend', '1.48.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-reference-frontend', '1.49.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-current-desktop-version', '1.0.32-rc.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-current-desktop-generation', 'managed_install');
|
||||
await expect(host).toHaveAttribute(
|
||||
'data-openclaw-current-desktop-hosted-version-mode',
|
||||
'installation_specific',
|
||||
);
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-generation', 'legacy_fixed_bundle');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-bridge-kind', 'electron_api');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-hosted-version-mode', 'fixed');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-version', '0.9.4');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-core-version', '0.22.3');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-embedded-frontend', '1.43.18');
|
||||
@@ -47,9 +75,44 @@ test.describe('Desktop host parity lane', () => {
|
||||
await clickTab(page, 'Approvals');
|
||||
await expect(page.locator('#apr-list')).toContainText('apr-r166-001');
|
||||
await expect(page.locator('#apr-list')).toContainText('desktop_host_smoke');
|
||||
expect(await page.evaluate(() => window.__openclawBridgeActivity)).toEqual({
|
||||
memberReads: [],
|
||||
calls: [],
|
||||
});
|
||||
await captureParityEvidence(page, testInfo, 'legacy-desktop-sidebar');
|
||||
});
|
||||
|
||||
test('stamps desktop host metadata on the admin console and refreshes approvals', async ({ page, baseURL }) => {
|
||||
test('boots the current Desktop sidebar without bridge access and keeps approvals interactive', async ({ page }, testInfo) => {
|
||||
await mockComfyUiCore(page, { hostSurface: 'comfy_desktop' });
|
||||
await mockCompatApprovalsList(page, [pendingApproval]);
|
||||
await page.goto('test-harness.html');
|
||||
await waitForOpenClawReady(page);
|
||||
|
||||
const host = page.locator('#sidebar-tab-comfyui-openclaw');
|
||||
await expect(host).toHaveAttribute('data-openclaw-host-surface', 'comfy_desktop');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-host', 'true');
|
||||
await expect(host).toHaveAttribute('data-openclaw-reference-frontend', '');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-generation', 'managed_install');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-bridge-kind', 'comfy_desktop2');
|
||||
await expect(host).toHaveAttribute(
|
||||
'data-openclaw-desktop-hosted-version-mode',
|
||||
'installation_specific',
|
||||
);
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-version', '1.0.32-rc.1');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-core-version', '');
|
||||
await expect(host).toHaveAttribute('data-openclaw-desktop-embedded-frontend', '');
|
||||
|
||||
await clickTab(page, 'Approvals');
|
||||
await expect(page.locator('#apr-list')).toContainText('apr-r166-001');
|
||||
await expect(page.locator('#apr-list')).toContainText('desktop_host_smoke');
|
||||
expect(await page.evaluate(() => window.__openclawBridgeActivity)).toEqual({
|
||||
memberReads: [],
|
||||
calls: [],
|
||||
});
|
||||
await captureParityEvidence(page, testInfo, 'current-desktop-sidebar');
|
||||
});
|
||||
|
||||
test('stamps legacy desktop host metadata on the admin console and refreshes approvals', async ({ page, baseURL }, testInfo) => {
|
||||
await mockRemoteAdminBaseline(page, {
|
||||
hostSurface: 'desktop',
|
||||
approvals: [pendingApproval],
|
||||
@@ -58,7 +121,31 @@ test.describe('Desktop host parity lane', () => {
|
||||
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-host-surface', 'desktop');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-desktop-host', 'true');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-reference-frontend', '1.48.1');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-reference-frontend', '1.49.1');
|
||||
await expect(page.locator('body')).toHaveAttribute(
|
||||
'data-openclaw-current-desktop-version',
|
||||
'1.0.32-rc.1',
|
||||
);
|
||||
await expect(page.locator('body')).toHaveAttribute(
|
||||
'data-openclaw-current-desktop-generation',
|
||||
'managed_install',
|
||||
);
|
||||
await expect(page.locator('body')).toHaveAttribute(
|
||||
'data-openclaw-current-desktop-hosted-version-mode',
|
||||
'installation_specific',
|
||||
);
|
||||
await expect(page.locator('body')).toHaveAttribute(
|
||||
'data-openclaw-desktop-generation',
|
||||
'legacy_fixed_bundle',
|
||||
);
|
||||
await expect(page.locator('body')).toHaveAttribute(
|
||||
'data-openclaw-desktop-bridge-kind',
|
||||
'electron_api',
|
||||
);
|
||||
await expect(page.locator('body')).toHaveAttribute(
|
||||
'data-openclaw-desktop-hosted-version-mode',
|
||||
'fixed',
|
||||
);
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-desktop-version', '0.9.4');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-desktop-core-version', '0.22.3');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-openclaw-desktop-embedded-frontend', '1.43.18');
|
||||
@@ -68,5 +155,42 @@ test.describe('Desktop host parity lane', () => {
|
||||
await page.locator('#refreshApprovals').click();
|
||||
await expect(page.locator('#approvalsList')).toContainText('apr-r166-001', { timeout: 15000 });
|
||||
await expect(page.locator('#approvalsList')).toContainText('desktop_host_smoke', { timeout: 15000 });
|
||||
expect(await page.evaluate(() => window.__openclawBridgeActivity)).toEqual({
|
||||
memberReads: [],
|
||||
calls: [],
|
||||
});
|
||||
await captureParityEvidence(page, testInfo, 'legacy-desktop-admin');
|
||||
});
|
||||
|
||||
test('stamps current Desktop metadata on the admin console and refreshes approvals', async ({ page, baseURL }, testInfo) => {
|
||||
await mockRemoteAdminBaseline(page, {
|
||||
hostSurface: 'comfy_desktop',
|
||||
approvals: [pendingApproval],
|
||||
});
|
||||
await page.goto(new URL('/web/admin_console.html', baseURL).toString());
|
||||
|
||||
const body = page.locator('body');
|
||||
await expect(body).toHaveAttribute('data-openclaw-host-surface', 'comfy_desktop');
|
||||
await expect(body).toHaveAttribute('data-openclaw-desktop-host', 'true');
|
||||
await expect(body).toHaveAttribute('data-openclaw-reference-frontend', '');
|
||||
await expect(body).toHaveAttribute('data-openclaw-desktop-generation', 'managed_install');
|
||||
await expect(body).toHaveAttribute('data-openclaw-desktop-bridge-kind', 'comfy_desktop2');
|
||||
await expect(body).toHaveAttribute(
|
||||
'data-openclaw-desktop-hosted-version-mode',
|
||||
'installation_specific',
|
||||
);
|
||||
await expect(body).toHaveAttribute('data-openclaw-desktop-version', '1.0.32-rc.1');
|
||||
await expect(body).toHaveAttribute('data-openclaw-desktop-core-version', '');
|
||||
await expect(body).toHaveAttribute('data-openclaw-desktop-embedded-frontend', '');
|
||||
|
||||
await waitForAdminConsoleReady(page);
|
||||
await page.locator('#refreshApprovals').click();
|
||||
await expect(page.locator('#approvalsList')).toContainText('apr-r166-001', { timeout: 15000 });
|
||||
await expect(page.locator('#approvalsList')).toContainText('desktop_host_smoke', { timeout: 15000 });
|
||||
expect(await page.evaluate(() => window.__openclawBridgeActivity)).toEqual({
|
||||
memberReads: [],
|
||||
calls: [],
|
||||
});
|
||||
await captureParityEvidence(page, testInfo, 'current-desktop-admin');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,13 @@ test.describe('Parameter Lab - Dynamic Dimensions', () => {
|
||||
widgets: [
|
||||
{ name: "seed", type: "number", value: 1234, options: {} },
|
||||
{ name: "steps", type: "number", value: 20, options: { values: [20, 30, 40] } },
|
||||
{ name: "sampler_name", type: "combo", value: "euler", options: { values: ["euler", "ddim", "uni_pc"] } }
|
||||
{ name: "sampler_name", type: "combo", value: "euler", options: { values: ["euler", "ddim", "uni_pc"] } },
|
||||
{
|
||||
name: "video_edit",
|
||||
type: "VIDEO_EDIT",
|
||||
value: { trim: [0, 1] },
|
||||
options: { values: [{ trim: [0, 1] }, ["structured"]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -87,6 +93,94 @@ test.describe('Parameter Lab - Dynamic Dimensions', () => {
|
||||
await expect(page.locator('.openclaw-chip >> text=9999')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects oversized manual scalar values before chip state mutation', async ({ page }) => {
|
||||
await page.click('#lab-add-dim');
|
||||
await page.selectOption('.dim-node-select', { value: '10' });
|
||||
await page.selectOption('.dim-widget-select', { value: 'seed' });
|
||||
|
||||
await page.fill('.dim-manual-input', '界'.repeat(5462));
|
||||
await page.press('.dim-manual-input', 'Enter');
|
||||
|
||||
await expect(page.locator('.openclaw-chip')).toHaveCount(0);
|
||||
await expect(page.locator('.openclaw-banner')).toContainText('scalar_string_too_large');
|
||||
});
|
||||
|
||||
test('does not offer structured widget values as ambiguous object candidates', async ({ page }) => {
|
||||
await page.click('#lab-add-dim');
|
||||
await page.selectOption('.dim-node-select', { value: '10' });
|
||||
await page.selectOption('.dim-widget-select', { value: 'video_edit' });
|
||||
|
||||
const candidates = page.locator('.dim-candidate-select option');
|
||||
await expect(candidates).toHaveCount(1);
|
||||
await expect(candidates).not.toContainText('[object Object]');
|
||||
});
|
||||
|
||||
test('caps dimensions before creating ambiguous experiment state', async ({ page }) => {
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
await page.click('#lab-add-dim');
|
||||
}
|
||||
|
||||
await expect(page.locator('.openclaw-lab-dim-row.dynamic')).toHaveCount(8);
|
||||
await expect(page.locator('.openclaw-banner')).toContainText('too_many_dimensions');
|
||||
});
|
||||
|
||||
test('rejects an oversized serialized workflow before the API request', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/web/openclaw_api.js');
|
||||
window.__labRequestCount = 0;
|
||||
const originalFetch = mod.openclawApi.fetch.bind(mod.openclawApi);
|
||||
mod.openclawApi.fetch = async (url, options = {}) => {
|
||||
const normalizedPath = String(url || '').replace(/^\/moltbot/, '/openclaw');
|
||||
if (normalizedPath.endsWith('/lab/sweep')) {
|
||||
window.__labRequestCount += 1;
|
||||
return { ok: false, status: 400, error: 'unexpected_request' };
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
window.app.graph.serialize = () => ({ payload: 'x'.repeat(4 * 1024 * 1024 + 1) });
|
||||
});
|
||||
|
||||
await page.click('#lab-add-dim');
|
||||
await page.selectOption('.dim-node-select', { value: '10' });
|
||||
await page.selectOption('.dim-widget-select', { value: 'seed' });
|
||||
await page.fill('.dim-manual-input', '1');
|
||||
await page.press('.dim-manual-input', 'Enter');
|
||||
await page.click('#lab-generate');
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.__labRequestCount)).toBe(0);
|
||||
await expect(page.locator('.openclaw-banner')).toContainText('workflow_too_large');
|
||||
});
|
||||
|
||||
test('redacts workflow serialization failures before the API request', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/web/openclaw_api.js');
|
||||
window.__labRequestCount = 0;
|
||||
const originalFetch = mod.openclawApi.fetch.bind(mod.openclawApi);
|
||||
mod.openclawApi.fetch = async (url, options = {}) => {
|
||||
const normalizedPath = String(url || '').replace(/^\/moltbot/, '/openclaw');
|
||||
if (normalizedPath.endsWith('/lab/sweep')) {
|
||||
window.__labRequestCount += 1;
|
||||
return { ok: false, status: 400, error: 'unexpected_request' };
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
window.app.graph.serialize = () => {
|
||||
throw new Error('secret=workflow-private-detail');
|
||||
};
|
||||
});
|
||||
|
||||
await page.click('#lab-add-dim');
|
||||
await page.selectOption('.dim-node-select', { value: '10' });
|
||||
await page.selectOption('.dim-widget-select', { value: 'seed' });
|
||||
await page.fill('.dim-manual-input', '1');
|
||||
await page.press('.dim-manual-input', 'Enter');
|
||||
await page.click('#lab-generate');
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.__labRequestCount)).toBe(0);
|
||||
await expect(page.locator('.openclaw-banner')).toContainText('invalid_payload');
|
||||
await expect(page.locator('.openclaw-banner')).not.toContainText('workflow-private-detail');
|
||||
});
|
||||
|
||||
test('generates correct plan payload', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/web/openclaw_api.js');
|
||||
@@ -285,4 +379,217 @@ test.describe('Parameter Lab - Dynamic Dimensions', () => {
|
||||
await page.selectOption('.dim-candidate-select', { value: 'xl.ckpt' });
|
||||
await expect(page.locator('.openclaw-chip >> text=xl.ckpt')).toBeVisible();
|
||||
});
|
||||
|
||||
test('uses an authoritative receipt when the host queue API returns only boolean', async ({ page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/web/openclaw_api.js');
|
||||
const { api } = await import('/scripts/api.js');
|
||||
const receiptMod = await import('/web/openclaw_parameter_lab_receipt.js');
|
||||
window.__labRunUpdates = [];
|
||||
window.__labQueueCalls = 0;
|
||||
window.__labSubmittedPromptIds = [];
|
||||
window.app.rootGraph = window.app.graph;
|
||||
window.app.processingQueue = false;
|
||||
window.app.queueItems = [];
|
||||
window.app.nextQueueRequestId = 1;
|
||||
window.app.graph.serialize = function () {
|
||||
const data = { nodes: [], extra: {} };
|
||||
this.onSerialize?.(data);
|
||||
return data;
|
||||
};
|
||||
window.app.queuePrompt = async function (number, batchCount = 1) {
|
||||
window.__labQueueCalls += 1;
|
||||
const requestId = this.nextQueueRequestId++;
|
||||
this.queueItems.push({ requestId, number, batchCount });
|
||||
api.dispatchCustomEvent('promptQueueing', { requestId, batchCount });
|
||||
if (this.processingQueue) return false;
|
||||
|
||||
this.processingQueue = true;
|
||||
await Promise.resolve();
|
||||
try {
|
||||
while (this.queueItems.length) {
|
||||
const request = this.queueItems.pop();
|
||||
let queuedCount = 0;
|
||||
for (let index = 0; index < request.batchCount; index += 1) {
|
||||
for (const node of this.graph._nodes) {
|
||||
for (const widget of node.widgets || []) {
|
||||
widget.beforeQueued?.({ isPartialExecution: false });
|
||||
}
|
||||
}
|
||||
const workflow = this.graph.serialize();
|
||||
const marker =
|
||||
workflow.extra?.[receiptMod.PARAMETER_LAB_RECEIPT_KEY];
|
||||
if (!marker?.prompt_id) throw new Error('missing receipt marker');
|
||||
delete workflow.extra[receiptMod.PARAMETER_LAB_RECEIPT_KEY];
|
||||
window.__labSubmittedPromptIds.push(marker.prompt_id);
|
||||
for (const node of this.graph._nodes) {
|
||||
for (const widget of node.widgets || []) {
|
||||
widget.afterQueued?.({ isPartialExecution: false });
|
||||
}
|
||||
}
|
||||
queuedCount += 1;
|
||||
}
|
||||
api.dispatchCustomEvent('promptQueued', {
|
||||
requestId: request.requestId,
|
||||
batchCount: queuedCount,
|
||||
number: request.number
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.processingQueue = false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const originalFetch = mod.openclawApi.fetch.bind(mod.openclawApi);
|
||||
mod.openclawApi.fetch = async (url, options = {}) => {
|
||||
const normalizedPath = String(url || '').replace(/^\/moltbot/, '/openclaw');
|
||||
if (normalizedPath.endsWith('/lab/sweep')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
plan: {
|
||||
experiment_id: 'exp_receipt',
|
||||
dimensions: [
|
||||
{
|
||||
node_id: 10,
|
||||
widget_name: 'seed',
|
||||
values: [42],
|
||||
strategy: 'grid'
|
||||
}
|
||||
],
|
||||
runs: [{ '10.seed': 42 }]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (normalizedPath.includes('/lab/experiments/exp_receipt/runs/0')) {
|
||||
window.__labRunUpdates.push(JSON.parse(options?.body || '{}'));
|
||||
return { ok: true, status: 200, data: {} };
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
});
|
||||
|
||||
await page.click('#lab-add-dim');
|
||||
await page.selectOption('.dim-node-select', { value: '10' });
|
||||
await page.selectOption('.dim-widget-select', { value: 'seed' });
|
||||
await page.fill('.dim-manual-input', '42');
|
||||
await page.press('.dim-manual-input', 'Enter');
|
||||
await page.click('#lab-generate');
|
||||
await expect(page.locator('#lab-run-all')).toBeVisible();
|
||||
await page.click('#lab-run-all');
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.__labQueueCalls)).toBe(1);
|
||||
await expect(page.locator('.openclaw-lab-run-item .run-status')).toContainText('Queued');
|
||||
const updates = await page.evaluate(() => window.__labRunUpdates);
|
||||
expect(updates).toEqual([
|
||||
expect.objectContaining({
|
||||
status: 'queued',
|
||||
output: { prompt_id: expect.any(String) }
|
||||
})
|
||||
]);
|
||||
const submitted = await page.evaluate(() => window.__labSubmittedPromptIds);
|
||||
expect(submitted).toHaveLength(1);
|
||||
expect(updates[0].output.prompt_id).toBe(submitted[0]);
|
||||
|
||||
await page.evaluate(async (promptId) => {
|
||||
const { api } = await import('/scripts/api.js');
|
||||
api.dispatchCustomEvent('execution_start', { prompt_id: promptId });
|
||||
}, submitted[0]);
|
||||
await expect(page.locator('.openclaw-lab-run-item .run-status')).toHaveText('Running');
|
||||
await expect.poll(() => page.evaluate(() => window.__labRunUpdates)).toEqual([
|
||||
expect.objectContaining({ status: 'queued' }),
|
||||
{ status: 'running' }
|
||||
]);
|
||||
|
||||
await page.evaluate(async (promptId) => {
|
||||
const { api } = await import('/scripts/api.js');
|
||||
api.dispatchCustomEvent('execution_success', {
|
||||
prompt_id: '00000000-0000-4000-8000-000000000000'
|
||||
});
|
||||
api.dispatchCustomEvent('execution_success', { prompt_id: promptId });
|
||||
api.dispatchCustomEvent('execution_error', { prompt_id: promptId });
|
||||
}, submitted[0]);
|
||||
await expect(page.locator('.openclaw-lab-run-item .run-status')).toHaveText('Completed');
|
||||
await expect.poll(() => page.evaluate(() => window.__labRunUpdates)).toEqual([
|
||||
expect.objectContaining({ status: 'queued' }),
|
||||
{ status: 'running' },
|
||||
{ status: 'completed' }
|
||||
]);
|
||||
|
||||
await expect(page.locator('.openclaw-banner')).toContainText(
|
||||
'All experiment runs finished.'
|
||||
);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const { api } = await import('/scripts/api.js');
|
||||
const watchedEvents = new Set([
|
||||
'promptQueueing',
|
||||
'promptQueued',
|
||||
'execution_start',
|
||||
'execution_success',
|
||||
'execution_error',
|
||||
'execution_interrupted'
|
||||
]);
|
||||
const widget = window.app.graph.getNodeById(10).widgets[0];
|
||||
const originalAddEventListener = api.addEventListener.bind(api);
|
||||
const originalRemoveEventListener = api.removeEventListener.bind(api);
|
||||
window.__labDisposeProbe = {
|
||||
listenerBalance: 0,
|
||||
originalBeforeQueued: widget.beforeQueued,
|
||||
originalAfterQueued: widget.afterQueued
|
||||
};
|
||||
api.addEventListener = (type, callback, options) => {
|
||||
if (watchedEvents.has(type)) {
|
||||
window.__labDisposeProbe.listenerBalance += 1;
|
||||
}
|
||||
return originalAddEventListener(type, callback, options);
|
||||
};
|
||||
api.removeEventListener = (type, callback, options) => {
|
||||
if (watchedEvents.has(type)) {
|
||||
window.__labDisposeProbe.listenerBalance -= 1;
|
||||
}
|
||||
return originalRemoveEventListener(type, callback, options);
|
||||
};
|
||||
window.app.processingQueue = false;
|
||||
window.app.queuePrompt = function (_number, batchCount = 1) {
|
||||
const requestId = this.nextQueueRequestId++;
|
||||
api.dispatchCustomEvent('promptQueueing', { requestId, batchCount });
|
||||
this.processingQueue = true;
|
||||
return new Promise(() => {});
|
||||
};
|
||||
});
|
||||
|
||||
await page.click('#lab-run-all');
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const widget = window.app.graph.getNodeById(10).widgets[0];
|
||||
const probe = window.__labDisposeProbe;
|
||||
return {
|
||||
callbacksWrapped:
|
||||
widget.beforeQueued !== probe.originalBeforeQueued &&
|
||||
widget.afterQueued !== probe.originalAfterQueued,
|
||||
listenerBalance: probe.listenerBalance
|
||||
};
|
||||
})).toEqual({ callbacksWrapped: true, listenerBalance: 6 });
|
||||
|
||||
await clickTab(page, 'Settings');
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const widget = window.app.graph.getNodeById(10).widgets[0];
|
||||
const probe = window.__labDisposeProbe;
|
||||
return {
|
||||
callbacksRestored:
|
||||
widget.beforeQueued === probe.originalBeforeQueued &&
|
||||
widget.afterQueued === probe.originalAfterQueued,
|
||||
listenerBalance: probe.listenerBalance,
|
||||
parameterPaneChildren:
|
||||
document.querySelector('#openclaw-tab-parameter-lab')?.childElementCount
|
||||
};
|
||||
})).toEqual({
|
||||
callbacksRestored: true,
|
||||
listenerBalance: 0,
|
||||
parameterPaneChildren: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,6 +275,99 @@ test.describe('R107 Live Backend Parity', () => {
|
||||
expect(assetApiCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('Job Monitor renders official advanced 3d results as one bounded view link', async ({ page }) => {
|
||||
const jobId = "job-advanced-3d-result";
|
||||
const metadataCanary = "metadata-value-must-not-project";
|
||||
let assetApiCalls = 0;
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__openclawOpenedUrls = [];
|
||||
window.open = (url, target) => {
|
||||
window.__openclawOpenedUrls.push({ url, target });
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
await page.route(`**/history/${jobId}`, async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[jobId]: {
|
||||
status: { status_str: "success", completed: true },
|
||||
outputs: {
|
||||
"9": {
|
||||
result: [
|
||||
"models/scene one.splat",
|
||||
{ camera: metadataCanary },
|
||||
[{ model: metadataCanary }],
|
||||
],
|
||||
},
|
||||
"10": {
|
||||
result: ["../private.glb"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/openclaw/trace/**', async route => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'not_found' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/assets**', async route => {
|
||||
assetApiCalls += 1;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'asset_api_should_not_be_called' }),
|
||||
});
|
||||
});
|
||||
|
||||
await clickTab(page, 'Jobs');
|
||||
await page.locator('input[placeholder="prompt_id"]').fill(jobId);
|
||||
await page.getByText('Add').click();
|
||||
|
||||
const jobRow = page.locator('.openclaw-job-row').first();
|
||||
await expect(page.locator('.openclaw-kv-val.ok')).toHaveText('completed', { timeout: 10000 });
|
||||
const fallbacks = jobRow.locator('.openclaw-job-output-media-fallback');
|
||||
await expect(fallbacks).toHaveCount(1);
|
||||
await expect(fallbacks).toContainText('3d output available');
|
||||
await expect(jobRow).not.toContainText(metadataCanary);
|
||||
await expect(jobRow.locator('img, canvas')).toHaveCount(0);
|
||||
|
||||
await fallbacks.click();
|
||||
const opened = await page.evaluate(() => {
|
||||
const entry = window.__openclawOpenedUrls[0];
|
||||
if (!entry) return null;
|
||||
const parsed = new URL(entry.url, window.location.origin);
|
||||
return {
|
||||
origin: parsed.origin,
|
||||
currentOrigin: window.location.origin,
|
||||
pathname: parsed.pathname,
|
||||
filename: parsed.searchParams.get("filename"),
|
||||
subfolder: parsed.searchParams.get("subfolder"),
|
||||
type: parsed.searchParams.get("type"),
|
||||
target: entry.target,
|
||||
};
|
||||
});
|
||||
expect(opened).toEqual({
|
||||
origin: opened.currentOrigin,
|
||||
currentOrigin: opened.currentOrigin,
|
||||
pathname: expect.stringMatching(/\/view$/),
|
||||
filename: "scene one.splat",
|
||||
subfolder: "models",
|
||||
type: "output",
|
||||
target: "_blank",
|
||||
});
|
||||
expect(assetApiCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('Job Monitor renders HDR image outputs as explicit fallbacks', async ({ page }) => {
|
||||
const jobId = "job-hdr-refs";
|
||||
let hdrViewRequests = 0;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { test, expect } from '@playwright/test';
|
||||
import { mockComfyUiCore, waitForOpenClawReady, clickTab } from '../utils/helpers.js';
|
||||
|
||||
async function routeTransientOpenClawEntryFailures(page, failureCount) {
|
||||
let remainingFailures = failureCount;
|
||||
let totalEntryRequests = 0;
|
||||
let abortedEntryRequests = 0;
|
||||
let remainingLogicalFailures = failureCount;
|
||||
let activeAttempt = null;
|
||||
let failActiveAttempt = false;
|
||||
let failedLogicalAttempts = 0;
|
||||
|
||||
// IMPORTANT: keep this query-agnostic; Windows CI must intercept the harness
|
||||
// retry seam even when the dynamic import URL carries cache-busting params.
|
||||
// IMPORTANT: fail by logical attempt, not raw request count; Chromium may retry
|
||||
// one module transport internally without advancing the harness retry loop.
|
||||
await page.route('**/web/openclaw.js**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname !== '/web/openclaw.js') {
|
||||
@@ -15,11 +18,30 @@ async function routeTransientOpenClawEntryFailures(page, failureCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
totalEntryRequests += 1;
|
||||
if (remainingFailures > 0) {
|
||||
remainingFailures -= 1;
|
||||
abortedEntryRequests += 1;
|
||||
await route.abort('failed');
|
||||
const attempt = Number.parseInt(
|
||||
url.searchParams.get('openclaw_harness_attempt') || '',
|
||||
10,
|
||||
);
|
||||
if (!Number.isInteger(attempt) || attempt < 1) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt !== activeAttempt) {
|
||||
activeAttempt = attempt;
|
||||
failActiveAttempt = remainingLogicalFailures > 0;
|
||||
if (failActiveAttempt) {
|
||||
remainingLogicalFailures -= 1;
|
||||
failedLogicalAttempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (failActiveAttempt) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'text/javascript',
|
||||
body: '// Injected transient module-fetch failure.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,8 +49,7 @@ async function routeTransientOpenClawEntryFailures(page, failureCount) {
|
||||
});
|
||||
|
||||
return {
|
||||
totalEntryRequests: () => totalEntryRequests,
|
||||
abortedEntryRequests: () => abortedEntryRequests,
|
||||
failedLogicalAttempts: () => failedLogicalAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,8 +145,7 @@ test.describe('OpenClaw Sidebar', () => {
|
||||
await page.reload();
|
||||
await waitForOpenClawReady(page);
|
||||
await expect(page.locator('.openclaw-title')).toHaveText('OpenClaw');
|
||||
expect(entryRetry.abortedEntryRequests()).toBe(1);
|
||||
expect(entryRetry.totalEntryRequests()).toBe(2);
|
||||
expect(entryRetry.failedLogicalAttempts()).toBe(1);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__openclawTestLoadAttempts))
|
||||
.toBe(2);
|
||||
@@ -137,8 +157,7 @@ test.describe('OpenClaw Sidebar', () => {
|
||||
await page.reload();
|
||||
await waitForOpenClawReady(page);
|
||||
await expect(page.locator('.openclaw-title')).toHaveText('OpenClaw');
|
||||
expect(entryRetry.abortedEntryRequests()).toBe(2);
|
||||
expect(entryRetry.totalEntryRequests()).toBe(3);
|
||||
expect(entryRetry.failedLogicalAttempts()).toBe(2);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__openclawTestLoadAttempts))
|
||||
.toBe(3);
|
||||
@@ -150,8 +169,7 @@ test.describe('OpenClaw Sidebar', () => {
|
||||
await page.reload();
|
||||
await waitForOpenClawReady(page);
|
||||
await expect(page.locator('.openclaw-title')).toHaveText('OpenClaw');
|
||||
expect(entryRetry.abortedEntryRequests()).toBe(3);
|
||||
expect(entryRetry.totalEntryRequests()).toBe(4);
|
||||
expect(entryRetry.failedLogicalAttempts()).toBe(3);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__openclawTestLoadAttempts))
|
||||
.toBe(4);
|
||||
@@ -163,7 +181,9 @@ test.describe('OpenClaw Sidebar', () => {
|
||||
await page.reload();
|
||||
await waitForOpenClawReady(page);
|
||||
await expect(page.locator('.openclaw-title')).toHaveText('OpenClaw');
|
||||
expect(entryRetry.abortedEntryRequests()).toBe(4);
|
||||
expect(entryRetry.totalEntryRequests()).toBe(5);
|
||||
expect(entryRetry.failedLogicalAttempts()).toBe(4);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__openclawTestLoadAttempts))
|
||||
.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+62
-16
@@ -3,6 +3,7 @@ import { expect } from '@playwright/test';
|
||||
const HOST_SURFACES = Object.freeze({
|
||||
standaloneFrontend: 'standalone_frontend',
|
||||
desktop: 'desktop',
|
||||
comfyDesktop: 'comfy_desktop',
|
||||
});
|
||||
|
||||
function resolveUiTimeoutMs() {
|
||||
@@ -70,6 +71,13 @@ function normalizeHostSurface(hostSurface) {
|
||||
if (hostSurface === HOST_SURFACES.desktop || hostSurface === 'desktop') {
|
||||
return HOST_SURFACES.desktop;
|
||||
}
|
||||
if (
|
||||
hostSurface === HOST_SURFACES.comfyDesktop
|
||||
|| hostSurface === 'current_desktop'
|
||||
|| hostSurface === 'managed_install'
|
||||
) {
|
||||
return HOST_SURFACES.comfyDesktop;
|
||||
}
|
||||
return HOST_SURFACES.standaloneFrontend;
|
||||
}
|
||||
|
||||
@@ -77,24 +85,55 @@ export async function installHostRuntime(page, { hostSurface = HOST_SURFACES.sta
|
||||
const resolvedHostSurface = normalizeHostSurface(hostSurface);
|
||||
|
||||
await page.addInitScript((options) => {
|
||||
const currentHostSurface = options?.hostSurface === 'desktop'
|
||||
? 'desktop'
|
||||
: 'standalone_frontend';
|
||||
const currentHostSurface = options?.hostSurface || 'standalone_frontend';
|
||||
|
||||
window.__openclawTestHostSurface = currentHostSurface;
|
||||
window.__openclawBridgeActivity = { memberReads: [], calls: [] };
|
||||
|
||||
const instrumentBridge = (kind, bridge) => new Proxy(bridge, {
|
||||
get(target, property, receiver) {
|
||||
window.__openclawBridgeActivity.memberReads.push(`${kind}.${String(property)}`);
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
if (currentHostSurface === 'desktop') {
|
||||
window.__DISTRIBUTION__ = 'desktop';
|
||||
window.electronAPI = window.electronAPI || {
|
||||
getPlatform: () => 'win32',
|
||||
window.electronAPI = instrumentBridge('electron_api', {
|
||||
getPlatform: () => {
|
||||
window.__openclawBridgeActivity.calls.push('electron_api.getPlatform');
|
||||
return 'win32';
|
||||
},
|
||||
platform: 'win32',
|
||||
versions: { electron: 'test' },
|
||||
};
|
||||
});
|
||||
try {
|
||||
delete window.__OPENCLAW_HOST_SURFACE__;
|
||||
} catch (error) {
|
||||
window.__OPENCLAW_HOST_SURFACE__ = undefined;
|
||||
}
|
||||
try {
|
||||
delete window.__DISTRIBUTION__;
|
||||
delete window.__comfyDesktop2;
|
||||
} catch (error) {
|
||||
window.__DISTRIBUTION__ = undefined;
|
||||
window.__comfyDesktop2 = undefined;
|
||||
}
|
||||
} else if (currentHostSurface === 'comfy_desktop') {
|
||||
window.__comfyDesktop2 = instrumentBridge('comfy_desktop2', {
|
||||
isRemote: () => {
|
||||
window.__openclawBridgeActivity.calls.push('comfy_desktop2.isRemote');
|
||||
return false;
|
||||
},
|
||||
});
|
||||
try {
|
||||
delete window.__OPENCLAW_HOST_SURFACE__;
|
||||
delete window.__DISTRIBUTION__;
|
||||
delete window.electronAPI;
|
||||
} catch (error) {
|
||||
window.__OPENCLAW_HOST_SURFACE__ = undefined;
|
||||
window.__DISTRIBUTION__ = undefined;
|
||||
window.electronAPI = undefined;
|
||||
}
|
||||
} else {
|
||||
window.__OPENCLAW_HOST_SURFACE__ = 'standalone_frontend';
|
||||
// IMPORTANT: current standalone frontend uses the localhost distribution
|
||||
@@ -103,8 +142,10 @@ export async function installHostRuntime(page, { hostSurface = HOST_SURFACES.sta
|
||||
window.__DISTRIBUTION__ = 'localhost';
|
||||
try {
|
||||
delete window.electronAPI;
|
||||
delete window.__comfyDesktop2;
|
||||
} catch (error) {
|
||||
window.electronAPI = undefined;
|
||||
window.__comfyDesktop2 = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,15 +213,20 @@ export async function mockComfyUiCore(page, options = {}) {
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
export const api = {
|
||||
fetchApi: async (route, options) => {
|
||||
// Prefix with /api if not already present (shim logic simulation)
|
||||
const url = "/api" + route;
|
||||
return fetch(url, options);
|
||||
},
|
||||
apiURL: (route) => "/api" + route,
|
||||
fileURL: (route) => route // Simplified for test
|
||||
};
|
||||
class OpenClawMockComfyApi extends EventTarget {
|
||||
async fetchApi(route, options) {
|
||||
// Prefix with /api if not already present (shim logic simulation)
|
||||
const url = "/api" + route;
|
||||
return fetch(url, options);
|
||||
}
|
||||
apiURL(route) { return "/api" + route; }
|
||||
fileURL(route) { return route; }
|
||||
dispatchCustomEvent(type, detail) {
|
||||
this.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
}
|
||||
}
|
||||
window.__openclawMockComfyApi ||= new OpenClawMockComfyApi();
|
||||
export const api = window.__openclawMockComfyApi;
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user