feat(api): add legacy compatibility governance

This commit is contained in:
rookiestar28
2026-04-26 18:05:31 +08:00
parent 8660ece6c1
commit d49e1d416f
5 changed files with 274 additions and 3 deletions
+19 -2
View File
@@ -52,6 +52,13 @@ else:
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
)
(build_legacy_route_deprecation_headers,) = import_attrs_dual(
__package__,
"..services.legacy_compat",
"services.legacy_compat",
("build_legacy_route_deprecation_headers",),
)
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
@@ -730,7 +737,15 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
print(
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
)
return await handler(request)
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
@@ -751,7 +766,9 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
targets = [path, "/api" + path]
for t in targets:
try:
server.app.router.add_route(method, t, handler)
# 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
+47
View File
@@ -0,0 +1,47 @@
# Legacy Compatibility Governance
OpenClaw keeps selected legacy compatibility aliases so older workflows, browser extensions, and deployment scripts have a predictable migration path. New integrations should use the canonical OpenClaw names.
Compatibility aliases are governed by explicit status, review cadence, telemetry, and removal criteria. An alias is not removed just because a canonical replacement exists; removal requires usage evidence and regression coverage.
## Status Labels
- `deprecated-observed`: the alias is still accepted, emits telemetry or warnings where practical, and should move to the canonical surface.
- `retained-compatibility`: the alias remains available for older workflows or deployments, with review based on diagnostics, tests, and operator reports.
## Review Policy
Every legacy alias has:
- a review cadence in days
- a telemetry or evidence signal
- a review trigger
- concrete removal criteria
Removal requires all of these conditions:
- no observed compatibility usage for two consecutive review windows
- a documented canonical migration path
- targeted regression coverage and release notes for the removal
## Governed Aliases
| Key | Surface | Legacy alias | Canonical surface | Status | Telemetry or evidence |
| --- | --- | --- | --- | --- | --- |
| `api-path-moltbot-prefix` | API path | `/moltbot/*` and `/api/moltbot/*` | `/openclaw/*` and `/api/openclaw/*` | `deprecated-observed` | `legacy_api_hits` |
| `header-x-moltbot-aliases` | Header | `X-Moltbot-*` request headers | `X-OpenClaw-*` request headers | `deprecated-observed` | `legacy_api_hits` and warning logs |
| `environment-moltbot-prefix` | Environment | `MOLTBOT_*` environment variables | `OPENCLAW_*` environment variables | `retained-compatibility` | configuration diagnostics and warning logs |
| `ui-class-moltbot-prefix` | UI class | `moltbot-*` CSS classes and local UI keys | `openclaw-*` CSS classes and local UI keys | `retained-compatibility` | frontend compatibility helper tests and operator reports |
| `workflow-node-moltbot-classes` | Workflow node | `Moltbot*` node class aliases and `moltbot` node category | `OpenClaw*` node classes and `openclaw` node category | `retained-compatibility` | workflow portability diagnostics and node-registration regression tests |
## Operator Visibility
Legacy API path requests expose deprecation response headers when the response type supports headers:
- `Deprecation: true`
- `X-OpenClaw-Compatibility-Key`
- `X-OpenClaw-Compatibility-Status`
- `X-OpenClaw-Compatibility-Telemetry`
- `X-OpenClaw-Canonical-Path`
Use these headers with server logs and `legacy_api_hits` to decide whether a deployment still depends on legacy route aliases.
+126 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Mapping, Optional, Tuple
from typing import Dict, Mapping, Optional, Tuple
OPENCLAW_API_PREFIX = "/openclaw"
LEGACY_API_PREFIX = "/moltbot"
@@ -22,6 +22,20 @@ class HeaderAlias:
legacy: str
@dataclass(frozen=True)
class LegacyCompatibilityEntry:
key: str
surface: str
legacy: str
canonical: str
status: str
review_cadence_days: int
telemetry_signal: str
removal_criteria: Tuple[str, ...]
review_trigger: str
operator_notice: str
ADMIN_TOKEN_HEADERS = HeaderAlias(
primary="X-OpenClaw-Admin-Token",
legacy="X-Moltbot-Admin-Token",
@@ -47,6 +61,84 @@ WEBHOOK_NONCE_HEADERS = HeaderAlias(
legacy="X-Moltbot-Nonce",
)
DEPRECATED_OBSERVED = "deprecated-observed"
RETAINED_COMPATIBILITY = "retained-compatibility"
LEGACY_ROUTE_COMPATIBILITY_KEY = "api-path-moltbot-prefix"
_STANDARD_REMOVAL_CRITERIA = (
"No observed compatibility usage for two consecutive review windows.",
"A public migration path exists for the canonical OpenClaw surface.",
"Removal is covered by targeted regression tests and release notes.",
)
_LEGACY_COMPATIBILITY_ENTRIES = (
LegacyCompatibilityEntry(
key=LEGACY_ROUTE_COMPATIBILITY_KEY,
surface="api_path",
legacy="/moltbot/* and /api/moltbot/*",
canonical="/openclaw/* and /api/openclaw/*",
status=DEPRECATED_OBSERVED,
review_cadence_days=90,
telemetry_signal="legacy_api_hits",
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
review_trigger="Review whenever legacy_api_hits remains zero for the review window or rises after a release.",
operator_notice="Use canonical /openclaw routes; legacy routes emit deprecation headers.",
),
LegacyCompatibilityEntry(
key="header-x-moltbot-aliases",
surface="header",
legacy="X-Moltbot-* request headers",
canonical="X-OpenClaw-* request headers",
status=DEPRECATED_OBSERVED,
review_cadence_days=90,
telemetry_signal="legacy_api_hits",
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
review_trigger="Review with API-path telemetry and any legacy-header warning logs.",
operator_notice="Prefer X-OpenClaw-* headers; legacy headers log deprecation warnings.",
),
LegacyCompatibilityEntry(
key="environment-moltbot-prefix",
surface="environment",
legacy="MOLTBOT_* environment variables",
canonical="OPENCLAW_* environment variables",
status=RETAINED_COMPATIBILITY,
review_cadence_days=180,
telemetry_signal="configuration diagnostics and deprecation warning logs",
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
review_trigger="Review when config diagnostics show no legacy env usage across supported deployment profiles.",
operator_notice="Prefer OPENCLAW_* variables; legacy MOLTBOT_* fallbacks remain compatibility-only.",
),
LegacyCompatibilityEntry(
key="ui-class-moltbot-prefix",
surface="ui_class",
legacy="moltbot-* CSS classes and local UI keys",
canonical="openclaw-* CSS classes and local UI keys",
status=RETAINED_COMPATIBILITY,
review_cadence_days=180,
telemetry_signal="frontend compatibility helper tests and user-reported extension compatibility",
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
review_trigger="Review when canonical frontend markup has shipped through two stable release windows.",
operator_notice="Use openclaw-* selectors for new integrations; moltbot-* aliases are generated for older extensions.",
),
LegacyCompatibilityEntry(
key="workflow-node-moltbot-classes",
surface="workflow_node",
legacy="Moltbot* node class aliases and moltbot node category",
canonical="OpenClaw* node classes and openclaw node category",
status=RETAINED_COMPATIBILITY,
review_cadence_days=180,
telemetry_signal="workflow portability diagnostics and node-registration regression tests",
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
review_trigger="Review after node metadata migration proves older workflows keep deterministic replacement hints.",
operator_notice="Keep canonical OpenClaw node names in new workflows; legacy Moltbot names remain for older workflow loads.",
),
)
_LEGACY_COMPATIBILITY_BY_KEY: Dict[str, LegacyCompatibilityEntry] = {
entry.key: entry for entry in _LEGACY_COMPATIBILITY_ENTRIES
}
def _header_value(headers: Mapping[str, str], name: str) -> str:
value = headers.get(name)
@@ -104,6 +196,39 @@ def get_header_alias_value(
return "", False
def iter_legacy_compatibility_entries() -> Tuple[LegacyCompatibilityEntry, ...]:
return _LEGACY_COMPATIBILITY_ENTRIES
def get_legacy_compatibility_entry(
key: str,
) -> Optional[LegacyCompatibilityEntry]:
return _LEGACY_COMPATIBILITY_BY_KEY.get(key)
def canonicalize_legacy_api_path(path: str) -> str:
if path.startswith("/api" + LEGACY_API_PREFIX + "/"):
return path.replace(LEGACY_API_PREFIX, OPENCLAW_API_PREFIX, 1)
if path.startswith(LEGACY_API_PREFIX + "/"):
return path.replace(LEGACY_API_PREFIX, OPENCLAW_API_PREFIX, 1)
return path
def build_legacy_route_deprecation_headers(path: str) -> Dict[str, str]:
canonical_path = canonicalize_legacy_api_path(path)
if canonical_path == path:
return {}
entry = _LEGACY_COMPATIBILITY_BY_KEY[LEGACY_ROUTE_COMPATIBILITY_KEY]
return {
"Deprecation": "true",
"X-OpenClaw-Compatibility-Key": entry.key,
"X-OpenClaw-Compatibility-Status": entry.status,
"X-OpenClaw-Compatibility-Telemetry": entry.telemetry_signal,
"X-OpenClaw-Canonical-Path": canonical_path,
}
def get_api_path_candidates(path: str) -> Tuple[str, ...]:
if path.startswith(OPENCLAW_API_PREFIX + "/"):
return (path, path.replace(OPENCLAW_API_PREFIX, LEGACY_API_PREFIX, 1))
+46
View File
@@ -1,12 +1,15 @@
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from services.legacy_compat import (
ADMIN_TOKEN_HEADERS,
LEGACY_API_PREFIX,
OPENCLAW_API_PREFIX,
build_legacy_route_deprecation_headers,
get_api_path_candidates,
get_header_alias_value,
iter_legacy_compatibility_entries,
)
@@ -57,6 +60,49 @@ class TestLegacyCompat(unittest.TestCase):
)
self.assertEqual(get_api_path_candidates("/history/abc"), ("/history/abc",))
def test_governance_registry_covers_remaining_legacy_alias_surfaces(self):
entries = tuple(iter_legacy_compatibility_entries())
surfaces = {entry.surface for entry in entries}
self.assertIn("api_path", surfaces)
self.assertIn("header", surfaces)
self.assertIn("environment", surfaces)
self.assertIn("ui_class", surfaces)
self.assertIn("workflow_node", surfaces)
for entry in entries:
with self.subTest(entry=entry.key):
self.assertTrue(entry.key)
self.assertTrue(entry.status)
self.assertGreater(entry.review_cadence_days, 0)
self.assertTrue(entry.telemetry_signal)
self.assertTrue(entry.removal_criteria)
self.assertTrue(entry.review_trigger)
def test_legacy_route_deprecation_headers_include_canonical_path(self):
headers = build_legacy_route_deprecation_headers("/api/moltbot/health")
self.assertEqual(headers["Deprecation"], "true")
self.assertEqual(headers["X-OpenClaw-Canonical-Path"], "/api/openclaw/health")
self.assertEqual(
headers["X-OpenClaw-Compatibility-Status"],
"deprecated-observed",
)
self.assertEqual(
headers["X-OpenClaw-Compatibility-Telemetry"],
"legacy_api_hits",
)
def test_governance_doc_mentions_every_registry_key(self):
repo_root = Path(__file__).resolve().parents[1]
doc = (repo_root / "docs" / "legacy-compatibility-governance.md").read_text(
encoding="utf-8"
)
for entry in iter_legacy_compatibility_entries():
with self.subTest(entry=entry.key):
self.assertIn(entry.key, doc)
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -1,4 +1,5 @@
import unittest
from asyncio import run
from unittest.mock import MagicMock
# from aiohttp import web # Removed for CI compatibility (no extra deps)
@@ -33,6 +34,41 @@ class TestRouteRegistration(unittest.TestCase):
self.assertIn("/moltbot/test", paths_registered)
self.assertIn("/api/moltbot/test", paths_registered)
def test_direct_legacy_fallback_uses_deprecation_wrapper(self):
server = MagicMock()
server.routes = MagicMock()
server.app = MagicMock()
server.app.router = MagicMock()
server.app.router.add_route = MagicMock()
class Response:
def __init__(self):
self.headers = {}
class Request:
path = "/api/moltbot/test"
async def handler(req):
return Response()
register_dual_route(server, "GET", "/moltbot/test", handler)
fallback_handler = None
for call in server.app.router.add_route.call_args_list:
if call.args[1] == "/api/moltbot/test":
fallback_handler = call.args[2]
break
self.assertIsNotNone(fallback_handler)
self.assertIsNot(fallback_handler, handler)
response = run(fallback_handler(Request()))
self.assertEqual(response.headers["Deprecation"], "true")
self.assertEqual(
response.headers["X-OpenClaw-Canonical-Path"],
"/api/openclaw/test",
)
if __name__ == "__main__":
unittest.main()