43 Commits
Author SHA1 Message Date
rookiestar28 822661ec81 fix(governance): bind acceptance gates to clean commits
Add the lightweight high-risk closeout helper and its regression coverage. Reject tracked, staged, or untracked public changes before and after full local validation so accepted results map to a reproducible commit.
2026-08-09 03:26:10 +08:00
rookiestar28 c649331ef5 chore(release): bump version to 1.0.7 2026-08-08 16:27:32 +08:00
rookiestar28 f086f7a0e8 fix(deps): update nanoid to patched release 2026-08-08 16:21:43 +08:00
rookiestar28 c46cae810f chore(release): bump version to 1.0.6 2026-08-03 18:23:36 +08:00
rookiestar28 397c9a1cbe style(tests): apply classifier test formatting 2026-08-03 18:18:38 +08:00
rookiestar28 71f399369c fix(ci): classify bootstrap route changes as high risk 2026-08-03 18:14:37 +08:00
rookiestar28 87c4c2df08 fix(startup): restore packaged route imports 2026-08-03 16:41:28 +08:00
rookiestar28 ec50c09b93 fix(tests): stabilize lifecycle snapshot assertions 2026-08-01 19:53:27 +08:00
rookiestar28 8970d6ff28 fix(ci): stabilize harness retry accounting 2026-08-01 19:43:44 +08:00
rookiestar28 76f535aa1a chore(release): bump version to 1.0.5 2026-07-31 17:02:34 +08:00
rookiestar28 9f6287f947 docs: align dependency hardening guidance 2026-07-31 16:49:52 +08:00
rookiestar28 816231c49f fix(deps): harden frontend dependency validation 2026-07-31 16:00:18 +08:00
rookiestar28 975843ac1a chore(release): bump version to 1.0.4 2026-07-31 14:56:39 +08:00
rookiestar28 b6760ee595 docs: summarize recent hardening and host alignment 2026-07-31 14:25:56 +08:00
rookiestar28 7d7a1c412f docs(audio): clarify native TTS ownership 2026-07-31 10:13:57 +08:00
rookiestar28 55d320db3d docs(workflows): clarify host workspace ownership 2026-07-31 09:52:14 +08:00
rookiestar28 e89b1c85a8 docs(nodes): document native media input flows 2026-07-31 09:30:29 +08:00
rookiestar28 a68dbfa433 fix(job-monitor): recognize advanced 3d results 2026-07-31 09:09:33 +08:00
rookiestar28 e5c1f48448 refactor(services): package bootstrap and posture domains 2026-07-31 08:44:04 +08:00
rookiestar28 22b4a341c2 fix(parameter-lab): correlate host queue receipts 2026-07-31 08:01:17 +08:00
rookiestar28 37f2507d37 fix(parameter-lab): bound experiment inputs 2026-07-31 07:03:52 +08:00
rookiestar28 c05436944d fix(security): contain posture evaluation errors 2026-07-31 06:42:01 +08:00
rookiestar28 3fafa42c93 feat(security): centralize effective startup posture 2026-07-31 06:27:51 +08:00
rookiestar28 8c175f47ab feat(startup): model bootstrap lifecycle outcomes 2026-07-31 05:40:58 +08:00
rookiestar28 5babb01a56 fix(compat): detect current desktop bridge 2026-07-31 05:34:55 +08:00
rookiestar28 85afda3277 fix(inventory): exclude dataset user data 2026-07-31 05:13:01 +08:00
rookiestar28 fe5bf6c684 feat(architecture): enforce production dependency boundaries 2026-07-31 04:59:02 +08:00
rookiestar28 351b418b83 fix(security): keep environment templates untracked 2026-07-31 04:24:09 +08:00
rookiestar28 291d537214 feat(compat): model desktop host generations 2026-07-31 04:22:38 +08:00
rookiestar28 570c4d0dc5 fix(ci): stabilize frontend retry assertions 2026-07-28 12:57:13 +08:00
rookiestar28 f1f221bb6d chore(release): bump version to 1.0.2 2026-07-11 18:39:05 +08:00
rookiestar28 76cbaa404f fix(ci): restrict static analysis to tracked sources 2026-07-11 18:20:47 +08:00
rookiestar28 c1aca449c5 fix(tests): stabilize contract digests across line endings 2026-07-11 17:25:34 +08:00
rookiestar28 a28316ed38 docs: refresh maintainability and verification guidance 2026-07-11 16:55:28 +08:00
rookiestar28 06a25f7393 test(coverage): promote governed floor to 55 percent 2026-07-11 10:29:57 +08:00
rookiestar28 bc06b56a5b refactor(frontend): decompose settings and API clients 2026-07-11 10:07:55 +08:00
rookiestar28 11faa8a789 refactor(connectors): decompose Slack and Feishu adapters 2026-07-11 08:51:33 +08:00
rookiestar28 b8b8d9c180 refactor(connector): decompose command router 2026-07-11 08:32:35 +08:00
rookiestar28 746d9a1352 refactor(config): decompose API handlers 2026-07-11 08:14:32 +08:00
rookiestar28 fdda687141 refactor(api): decompose route ownership 2026-07-11 07:52:08 +08:00
rookiestar28 b68d951fd0 fix(robustness): harden exception boundaries 2026-07-11 07:25:15 +08:00
rookiestar28 10c8f2e4aa test(performance): add deterministic scale baselines 2026-07-11 06:57:07 +08:00
rookiestar28 ed98ed6568 chore(quality): enforce incremental static analysis 2026-07-11 06:33:08 +08:00
165 changed files with 35753 additions and 7120 deletions
+7 -2
View File
@@ -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
+4
View File
@@ -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
+16
View File
@@ -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
+128 -52
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+283
View File
@@ -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
)
+182
View File
@@ -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
)
+244
View File
@@ -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
)
+445
View File
@@ -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})
+206
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+332
View File
@@ -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
# -------------------------------------------------------------------------
+244
View File
@@ -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)
+275
View File
@@ -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 ---
+217
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+3 -3
View File
@@ -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
-20
View File
@@ -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
+19
View File
@@ -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
+8 -1
View File
@@ -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.
+35 -8
View File
@@ -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.
+2 -2
View File
@@ -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` |
---
+7 -3
View File
@@ -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:
+31 -12
View File
@@ -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:
+22 -8
View File
@@ -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)
+68 -2
View File
@@ -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.
+4 -3
View File
@@ -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
+8 -1
View File
@@ -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`.
+36 -4
View File
@@ -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`),
+4 -1
View File
@@ -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
+10 -10
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
+7 -1
View File
@@ -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,
+10
View File
@@ -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");
}
+23
View File
@@ -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)
+258
View File
@@ -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())
+23
View File
@@ -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"
+132 -3
View File
@@ -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
+11 -4
View File
@@ -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)
+34 -4
View File
@@ -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"
+35 -6
View File
@@ -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"
+154
View File
@@ -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())
+178
View File
@@ -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())
+133
View File
@@ -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())
+104 -2
View File
@@ -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);
}
+158
View File
@@ -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())
+933
View File
@@ -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())
+628
View File
@@ -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())
+4
View File
@@ -0,0 +1,4 @@
# ruff: noqa: N999
"""Bootstrap lifecycle and registration implementation package."""
__all__ = ["lifecycle", "registration"]
+698
View File
@@ -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,
)
+623
View File
@@ -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()
+23 -4
View File
@@ -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,
+71 -1
View File
@@ -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:
+238 -5
View File
@@ -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")
+4 -2
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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
+1
View File
@@ -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",
+27 -4
View File
@@ -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
View File
@@ -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)
+253
View File
@@ -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
+90
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# ruff: noqa: N999
"""Process-static security posture implementation package."""
__all__ = ["effective"]
+511
View File
@@ -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),
}
+8 -2
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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(
+10 -2
View File
@@ -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:
+10 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+41 -4
View File
@@ -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}'.")
+23 -3
View File
@@ -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
View File
@@ -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
+205
View File
@@ -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"
}
+743
View File
@@ -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
}
+575
View File
@@ -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."
}
]
}
+172
View File
@@ -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"
}
}
+13 -3
View File
@@ -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": []
+110
View File
@@ -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"
}
]
}
+129 -5
View File
@@ -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');
});
});
+308 -1
View File
@@ -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
});
});
});
+93
View File
@@ -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;
+38 -18
View File
@@ -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
View File
@@ -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