feat(host188): forward controlled callback correlation

This commit is contained in:
Your Name
2026-07-18 17:03:30 +08:00
parent 7d1968835a
commit 6de70e1910
9 changed files with 2725 additions and 25 deletions

View File

@@ -251,14 +251,14 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = {
"set -euo pipefail; "
"root=/home/ollama/.local/share/awoooi-controlled-apply/"
"openclaw-callback-forwarder/"
"payloads/ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9 && "
"payloads/8f367f8e7d6104c44b29bd5fa2d894db09ddf365 && "
"manifest=\"$root/manifest.json\" && "
"test -d \"$root\" && test ! -L \"$root\" && "
"test \"$(stat -c '%U:%G:%a' \"$root\")\" = 'ollama:ollama:755' && "
"test -f \"$manifest\" && test ! -L \"$manifest\" && "
"test \"$(stat -c '%U:%G:%a' \"$manifest\")\" = 'ollama:ollama:444' && "
"test \"$(sha256sum \"$manifest\" | cut -d' ' -f1)\" = "
"dafeb467b744ee7e80dc043d51f9e5d7e6905a58103f1710d519b553b56c6e48",
"0e5edaf700d264b44cc0eae3b9429af642be0b1563a0bffa633942d30d2c2e1c",
),
AssetPostcondition(
"host_188_openclaw_callback_overlay_enabled",
@@ -273,7 +273,7 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = {
"test \"$(stat -c '%U:%G:%a' \"$override\")\" = "
"'ollama:ollama:444' && "
"test \"$(sha256sum \"$override\" | cut -d' ' -f1)\" = "
"8f6b7e8a0d6746c1331e99910656e64deebf67c9582bab3e1d36f71bb7a253c0",
"33c4594d4e4092b2a2367583a67f4c9a82ead0d28ca5078eca4896276084da0d",
),
AssetPostcondition(
"host_188_openclaw_callback_payload_runtime",
@@ -282,7 +282,7 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = {
"set -euo pipefail; "
"root=/home/ollama/.local/share/awoooi-controlled-apply/"
"openclaw-callback-forwarder/"
"payloads/ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9 && "
"payloads/8f367f8e7d6104c44b29bd5fa2d894db09ddf365 && "
"telegram=\"$root/app/bot/telegram.py\" && "
"config=\"$root/app/core/config.py\" && "
"forwarder=\"$root/app/services/telegram_callback_forwarder.py\" && "
@@ -297,7 +297,7 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = {
"test -f \"$forwarder\" && test ! -L \"$forwarder\" && "
"test \"$(stat -c '%U:%G:%a' \"$forwarder\")\" = 'ollama:ollama:444' && "
"test \"$(sha256sum \"$forwarder\" | cut -d' ' -f1)\" = "
"0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3 && "
"8e8e0faf6322abb21d670ad9094f1805d7a4b3643da8fe88d74a8c9591066bf3 && "
"ids=$(docker ps -q "
"--filter label=com.docker.compose.project=clawbot "
"--filter label=com.docker.compose.service=clawbot) && "
@@ -314,7 +314,7 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = {
"e1b8b633f7b4780d3078d6c42a55e49d200255d3597a34e0cd2fd649b84eaea1 && "
"test \"$(docker exec \"$ids\" sha256sum "
"/app/app/services/telegram_callback_forwarder.py | cut -d' ' -f1)\" = "
"0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3 && "
"8e8e0faf6322abb21d670ad9094f1805d7a4b3643da8fe88d74a8c9591066bf3 && "
"test \"$(docker inspect --format='{{range .Mounts}}{{if eq "
".Destination \"/app/app/bot/telegram.py\"}}{{.Source}}|"
"{{.Destination}}|{{.Type}}|{{.RW}}{{end}}{{end}}' \"$id\")\" = "

View File

@@ -31,12 +31,13 @@ SERVICE_REGISTRY = ROOT / "ops" / "config" / "service-registry.yaml"
MONITORING_REGISTRY = ROOT / "ops" / "monitoring" / "service-registry.yaml"
CATALOG_ID = "ansible:188-openclaw-callback-forwarder"
CANONICAL_ASSET_ID = "service:openclaw:host188"
EXPECTED_SHA = "ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9"
EXPECTED_SHA = "8f367f8e7d6104c44b29bd5fa2d894db09ddf365"
UPSTREAM_SOURCE_COMMIT = "ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9"
EXPECTED_MANIFEST_SHA256 = (
"dafeb467b744ee7e80dc043d51f9e5d7e6905a58103f1710d519b553b56c6e48"
"0e5edaf700d264b44cc0eae3b9429af642be0b1563a0bffa633942d30d2c2e1c"
)
EXPECTED_OVERRIDE_SHA256 = (
"8f6b7e8a0d6746c1331e99910656e64deebf67c9582bab3e1d36f71bb7a253c0"
"33c4594d4e4092b2a2367583a67f4c9a82ead0d28ca5078eca4896276084da0d"
)
PAYLOAD_ROOT = (
ROOT
@@ -54,7 +55,7 @@ EXPECTED_PAYLOAD_SHA256 = {
"e1b8b633f7b4780d3078d6c42a55e49d200255d3597a34e0cd2fd649b84eaea1"
),
"app/services/telegram_callback_forwarder.py": (
"0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3"
"8e8e0faf6322abb21d670ad9094f1805d7a4b3643da8fe88d74a8c9591066bf3"
),
}
@@ -353,6 +354,15 @@ def test_openclaw_playbook_is_check_first_bounded_and_rollback_capable() -> None
"/home/ollama/.local/share/awoooi-controlled-apply/"
f"openclaw-callback-forwarder/payloads/{EXPECTED_SHA}"
),
).replace(
"{{ callback_identity_path }}",
(
"/home/ollama/.local/share/awoooi-controlled-apply/"
"openclaw-callback-forwarder/receipts/current-deployment.json"
),
).replace(
"{{ callback_identity_container_path }}",
"/run/awoooi/openclaw-callback-forwarder/current-deployment.json",
)
assert hashlib.sha256(rendered_override.encode()).hexdigest() == (
EXPECTED_OVERRIDE_SHA256
@@ -411,7 +421,9 @@ def test_openclaw_payload_manifest_is_hash_pinned_and_overlay_only() -> None:
assert hashlib.sha256(manifest_path.read_bytes()).hexdigest() == (
EXPECTED_MANIFEST_SHA256
)
assert manifest["source_commit"] == EXPECTED_SHA
assert manifest["source_commit"] == UPSTREAM_SOURCE_COMMIT
assert manifest["overlay_revision"] == EXPECTED_SHA
assert manifest["overlay_revision_kind"] == "git_blob_of_callback_forwarder"
assert manifest["deployment_mode"] == (
"host_user_owned_read_only_compose_override_no_build_no_pull"
)

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[3]
REVISION = "8f367f8e7d6104c44b29bd5fa2d894db09ddf365"
PAYLOAD = (
ROOT
/ "infra"
/ "ansible"
/ "files"
/ "openclaw-callback-forwarder"
/ REVISION
/ "app"
/ "services"
/ "telegram_callback_forwarder.py"
)
def _load_payload() -> ModuleType:
spec = importlib.util.spec_from_file_location(
"host188_controlled_callback_forwarder",
PAYLOAD,
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _identity() -> dict[str, str]:
return {
"schema_version": "openclaw_callback_deployment_identity_v1",
"catalog_id": "ansible:188-openclaw-callback-forwarder",
"trace_id": "trace:host188:001",
"run_id": "run:host188:001",
"work_item_id": "AIA-SRE-017",
}
def _update() -> dict:
return {
"update_id": 77,
"callback_query": {
"data": "detail:INC-20260716-TEST",
"from": {"id": 7, "username": "operator"},
"id": "callback-forward-77",
"message": {
"chat": {"id": -1001},
"message_id": 9,
"text": "incident",
},
},
}
class _CaptureClient:
def __init__(self) -> None:
self.body = b""
async def post(self, _url: str, *, content: bytes, headers: dict) -> object:
self.body = content
assert headers["X-Telegram-Forward-Schema"] == (
"telegram_callback_forward_v1"
)
return SimpleNamespace(status_code=200)
def test_identity_loader_accepts_exact_regular_file_and_rejects_unsafe_shapes(
tmp_path: Path,
) -> None:
module = _load_payload()
identity_path = tmp_path / "current-deployment.json"
identity_path.write_text(json.dumps(_identity()), encoding="utf-8")
assert module.load_controlled_correlation(str(identity_path)) == {
"schema_version": "awoooi_controlled_callback_correlation_v1",
"trace_id": "trace:host188:001",
"run_id": "run:host188:001",
"work_item_id": "AIA-SRE-017",
}
identity_path.write_text(json.dumps({**_identity(), "extra": "no"}), encoding="utf-8")
assert module.load_controlled_correlation(str(identity_path)) is None
identity_path.write_text("x" * 4097, encoding="utf-8")
assert module.load_controlled_correlation(str(identity_path)) is None
target = tmp_path / "target.json"
target.write_text(json.dumps(_identity()), encoding="utf-8")
identity_path.unlink()
identity_path.symlink_to(target)
assert module.load_controlled_correlation(str(identity_path)) is None
@pytest.mark.asyncio
async def test_forwarder_adds_exact_correlation_and_preserves_legacy_fallback(
tmp_path: Path,
) -> None:
module = _load_payload()
identity_path = tmp_path / "current-deployment.json"
identity_path.write_text(json.dumps(_identity()), encoding="utf-8")
client = _CaptureClient()
forwarder = module.TelegramCallbackForwarder(
enabled=True,
url="https://awoooi.wooo.work/api/v1/telegram/callback-forward",
signing_key="test-key",
controlled_identity_path=str(identity_path),
)
result = await forwarder.forward(_update(), now=1, client=client)
assert result.acknowledged is True
assert json.loads(client.body)["controlled_correlation"] == {
"schema_version": "awoooi_controlled_callback_correlation_v1",
"trace_id": "trace:host188:001",
"run_id": "run:host188:001",
"work_item_id": "AIA-SRE-017",
}
identity_path.unlink()
legacy_client = _CaptureClient()
legacy_result = await forwarder.forward(
_update(),
now=2,
client=legacy_client,
)
assert legacy_result.acknowledged is True
assert "controlled_correlation" not in json.loads(legacy_client.body)

View File

@@ -15,7 +15,7 @@ CATALOG_ID = "ansible:188-openclaw-callback-forwarder"
EXPECTED_PAYLOAD_SHA256 = {
"af9600f32305ae73184651a8d3437063d7c8d35e3c3eb0910a2e27ac874c9302",
"e1b8b633f7b4780d3078d6c42a55e49d200255d3597a34e0cd2fd649b84eaea1",
"0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3",
"8e8e0faf6322abb21d670ad9094f1805d7a4b3643da8fe88d74a8c9591066bf3",
}
FRESH_RECEIPT_BLOCKER = (
"fresh_callback_receipt_run_scope_unavailable_in_static_postcondition_registry"
@@ -72,8 +72,8 @@ def test_host188_overlay_probes_are_exact_and_secret_blind() -> None:
assert "stat -c '%U:%G:%a'" in joined
assert "ollama:ollama:444" in joined
assert "ollama:ollama:755" in joined
assert "dafeb467b744ee7e80dc043d51f9e5d7e6905a58103f1710d519b553b56c6e48" in joined
assert "8f6b7e8a0d6746c1331e99910656e64deebf67c9582bab3e1d36f71bb7a253c0" in joined
assert "0e5edaf700d264b44cc0eae3b9429af642be0b1563a0bffa633942d30d2c2e1c" in joined
assert "33c4594d4e4092b2a2367583a67f4c9a82ead0d28ca5078eca4896276084da0d" in joined
assert "docker-compose.override.yml" in joined
assert "COMPOSE_FILE" not in joined
assert all(digest in joined for digest in EXPECTED_PAYLOAD_SHA256)

View File

@@ -0,0 +1,252 @@
"""
OpenClaw v5.0 - Core Configuration
企業級 AIOps 閉環架構配置模組
使用 Pydantic Settings 進行嚴格環境變數驗證
"""
from functools import lru_cache
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""OpenClaw v5.0 配置 - 所有環境變數的單一真理來源"""
# ============ 應用程式基本設定 ============
APP_NAME: str = "OpenClaw"
APP_VERSION: str = "5.0.0"
APP_ENV: Literal["development", "staging", "production"] = "development"
DEBUG: bool = False
HOST: str = "0.0.0.0"
PORT: int = 8088
# ============ Telegram Bot 設定 ============
TELEGRAM_BOT_TOKEN: str = Field(..., description="Telegram Bot API Token")
TELEGRAM_ADMIN_CHAT_ID: str = Field(..., description="管理員 Chat ID (用於告警)")
TELEGRAM_ALLOWED_USERS: str = Field(
default="",
description="允許操作的用戶 ID逗號分隔"
)
TELEGRAM_CALLBACK_FORWARD_ENABLED: bool = Field(
default=False,
description="明確啟用 AWOOOI 讀取型 callback HMAC forwarder",
)
TELEGRAM_CALLBACK_FORWARD_URL: str = Field(
default="https://awoooi.wooo.work/api/v1/telegram/callback-forward",
description="AWOOOI callback ingress只允許 HTTPS allowlisted endpoint",
)
TELEGRAM_CALLBACK_FORWARD_ALLOWED_HOSTS: str = Field(
default="awoooi.wooo.work",
description="允許的 callback forward host逗號分隔",
)
TELEGRAM_CALLBACK_FORWARD_TIMEOUT_SECONDS: float = Field(
default=3.0,
ge=0.25,
le=10.0,
description="callback forward 總 HTTP timeout 秒數",
)
TELEGRAM_CALLBACK_FORWARD_MAX_BODY_BYTES: int = Field(
default=16384,
ge=1024,
le=32768,
description="canonical callback body 大小上限",
)
@field_validator("TELEGRAM_ALLOWED_USERS")
@classmethod
def parse_allowed_users(cls, v: str) -> str:
return v
def get_allowed_user_ids(self) -> list[int]:
"""解析允許的用戶 ID 列表"""
if not self.TELEGRAM_ALLOWED_USERS:
return []
return [int(uid.strip()) for uid in self.TELEGRAM_ALLOWED_USERS.split(",") if uid.strip()]
def get_callback_forward_allowed_hosts(self) -> frozenset[str]:
"""解析 callback destination allowlist空清單會讓 forwarder fail closed。"""
return frozenset(
host.strip().lower()
for host in self.TELEGRAM_CALLBACK_FORWARD_ALLOWED_HOSTS.split(",")
if host.strip()
)
# ============ SigNoz / OpenTelemetry 設定 ============
OTEL_EXPORTER_OTLP_ENDPOINT: str = Field(
default="http://localhost:4317",
description="OpenTelemetry Collector gRPC 端點"
)
OTEL_SERVICE_NAME: str = "clawbot"
SIGNOZ_API_URL: str = Field(
default="http://localhost:3301",
description="SigNoz API 端點"
)
# ============ n8n 整合設定 ============
N8N_WEBHOOK_URL: str = Field(
default="http://localhost:5678",
description="n8n Webhook 基礎 URL"
)
N8N_API_KEY: str = Field(default="", description="n8n API Key (可選)")
# ============ Grafana OnCall 設定 ============
GRAFANA_ONCALL_URL: str = Field(
default="",
description="Grafana OnCall Webhook URL"
)
GRAFANA_ONCALL_API_KEY: str = Field(default="", description="Grafana OnCall API Key")
# ============ LLM 設定 ============
# 2026-04-01 ogt: 修復生產 AI 無法使用 — 預設改 ollamaOLLAMA_BASE_URL 指向 AI 主機
# 原因: ANTHROPIC_API_KEY 未注入容器環境導致 "AI 暫時無法使用" 錯誤
# fallback 順序: Ollama → Gemini → Claude (feedback_ai_fallback_order.md)
LLM_PROVIDER: Literal["anthropic", "openai", "ollama"] = "ollama"
ANTHROPIC_API_KEY: str = Field(default="", description="Anthropic Claude API Key")
OPENAI_API_KEY: str = Field(default="", description="OpenAI API Key")
# 2026-03-31 ogt: NVIDIA NIM / 自訂 OpenAI 相容端點 (空字串=使用預設 api.openai.com)
OPENAI_BASE_URL: str = Field(
default="",
description="OpenAI 相容 API Base URL (e.g. https://integrate.api.nvidia.com/v1)"
)
OLLAMA_BASE_URL: str = Field(
default="http://192.168.0.188:11434",
description="Ollama LLM 端點 (AI 主機 192.168.0.188)"
)
LLM_MODEL: str = Field(
default="qwen2.5:7b-instruct",
description="預設 LLM 模型 (Ollama: qwen2.5:7b-instruct)"
)
# ============ Git 設定 ============
GITLAB_URL: str = Field(
default="http://192.168.0.110",
description="GitLab 伺服器 URL"
)
GITLAB_TOKEN: str = Field(default="", description="GitLab Personal Access Token")
GITHUB_TOKEN: str = Field(default="", description="GitHub Personal Access Token")
GIT_DEFAULT_BRANCH: str = "main"
# ============ Gitea 設定 (知識沉澱) ============
GITEA_URL: str = Field(
default="http://192.168.0.110:3001",
description="Gitea 伺服器 URL"
)
GITEA_TOKEN: str = Field(default="", description="Gitea Personal Access Token")
GITEA_OWNER: str = Field(default="wooo", description="Gitea 組織/用戶名")
GITEA_REPO: str = Field(default="wooo-aiops", description="預設 Issue 存放的 Repository")
# ============ Ansible 設定 ============
ANSIBLE_PLAYBOOKS_PATH: str = Field(
default="/opt/clawbot/ansible/playbooks",
description="Ansible Playbooks 目錄"
)
ANSIBLE_INVENTORY_PATH: str = Field(
default="/opt/clawbot/ansible/inventory",
description="Ansible Inventory 檔案路徑"
)
ANSIBLE_SSH_KEY_PATH: str = Field(
default="~/.ssh/id_rsa",
description="SSH 私鑰路徑"
)
# ============ Redis 設定 (用於狀態/快取) ============
REDIS_URL: str = Field(
default="redis://localhost:6379/0",
description="Redis 連線 URL"
)
# ============ OPS.86 Enterprise AI Stack Feature Flags ============
USE_INSTRUCTOR_PARSER: bool = Field(
default=True,
description="Feature Flag: 啟用 Instructor 結構化輸出 (設 false 回退到 legacy JSON 解析)"
)
SHADOW_MODE_ENABLED: bool = Field(
default=False,
description="Shadow Mode: 同時執行新舊邏輯並比對結果 (用於驗證 Instructor 準確率)"
)
# ============ OPS.87 Semantic Cache 設定 ============
SEMANTIC_CACHE_ENABLED: bool = Field(
default=True,
description="Feature Flag: 啟用語意快取層"
)
SEMANTIC_CACHE_TTL_SECONDS: int = Field(
default=3600,
description="快取 TTL (秒) - 預設 1 小時"
)
SEMANTIC_CACHE_SIMILARITY_THRESHOLD: float = Field(
default=0.92,
description="語意相似度閾值 (0.0-1.0) - 超過此值視為快取命中"
)
SEMANTIC_CACHE_USE_VECTOR_SEARCH: bool = Field(
default=False,
description="使用 Redis Stack 向量搜索 (需升級 Redis)"
)
EMBEDDING_MODEL: str = Field(
default="nomic-embed-text",
description="Ollama Embedding 模型名稱"
)
# ============ 安全設定 ============
API_SECRET_KEY: str = Field(
default="change-me-in-production",
description="API 簽名密鑰"
)
APPROVAL_TIMEOUT_SECONDS: int = Field(
default=300,
description="審批超時時間 (秒)"
)
# ============ HA 高可用設定 ============
STANDBY_MODE: bool = Field(
default=False,
description="Standby 模式 - 不啟動 Telegram polling僅運行 API"
)
INSTANCE_ID: str = Field(
default="primary",
description="實例識別碼 (primary / standby)"
)
PRIMARY_HEALTH_URL: str = Field(
default="http://192.168.0.188:8088/health",
description="Primary 實例健康檢查 URL"
)
# ============ L3 Dead Man's Switch (Healthchecks.io) ============
HEALTHCHECKS_PING_URL: str = Field(
default="",
description="Healthchecks.io Ping URL (格式: https://hc-ping.com/your-uuid)"
)
# ============ 監控目標 ============
MONITORED_SITES: str = Field(
default="https://aiops.wooo.work,https://mo.wooo.work",
description="監控的網站列表,逗號分隔"
)
def get_monitored_sites(self) -> list[str]:
"""解析監控網站列表"""
return [s.strip() for s in self.MONITORED_SITES.split(",") if s.strip()]
# ============ Playwright 設定 ============
PLAYWRIGHT_HEADLESS: bool = True
PLAYWRIGHT_TIMEOUT_MS: int = 30000
SCREENSHOT_PATH: str = "/tmp/clawbot/screenshots"
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": True,
"extra": "ignore"
}
@lru_cache
def get_settings() -> Settings:
"""取得快取的設定實例"""
return Settings()
# 快捷存取
settings = get_settings()

View File

@@ -0,0 +1,408 @@
"""Fail-closed forwarding for AWOOOI-owned Telegram callback queries.
ClawBot owns the Telegram ``getUpdates`` loop. AWOOOI may render a small set
of read-only buttons, so those callback updates have to cross the service
boundary without transferring the bot token or giving unknown/write actions a
new execution path.
The forwarder deliberately serializes a minimal Telegram Update projection,
signs the exact compact body, follows no redirects, and treats only a 2xx
response as an acknowledgement. It never logs callback payloads, actor ids,
the signing key, or upstream response bodies.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import stat
import time
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit
import httpx
AWOOOI_READ_CALLBACK_ACTIONS = frozenset(
{
"detail",
"history",
"reanalyze",
"drift_view_page",
"check_process",
"check_log_nginx",
"check_log_container",
"check_log_minio",
"check_port",
"check_health",
"check_pod_logs",
"describe_pod",
"open_signoz",
"open_flywheel",
"backup_check_host_disk",
"backup_check_jobs",
"backup_check_velero",
}
)
CLAWBOT_APPROVAL_CALLBACK_PATTERN = r"^(?:repair|rag|approve|reject):"
AWOOOI_READ_CALLBACK_PATTERN = (
r"^(?:"
+ "|".join(sorted(map(re.escape, AWOOOI_READ_CALLBACK_ACTIONS)))
+ r"):[A-Za-z0-9][A-Za-z0-9_.@/-]*$"
)
_EXPECTED_PATH = "/api/v1/telegram/callback-forward"
_CONTROLLED_IDENTITY_PATH = (
"/run/awoooi/openclaw-callback-forwarder/current-deployment.json"
)
_CONTROLLED_IDENTITY_MAX_BYTES = 4_096
_CONTROLLED_IDENTITY_KEYS = frozenset(
{"schema_version", "catalog_id", "trace_id", "run_id", "work_item_id"}
)
_CONTROLLED_IDENTITY_SCHEMA = "openclaw_callback_deployment_identity_v1"
_CONTROLLED_CORRELATION_SCHEMA = "awoooi_controlled_callback_correlation_v1"
_CONTROLLED_CATALOG_ID = "ansible:188-openclaw-callback-forwarder"
_SAFE_CORRELATION_ID_RE = re.compile(
r"^[A-Za-z0-9](?:[A-Za-z0-9_.:-]{0,158}[A-Za-z0-9])?$"
)
_SAFE_VALUE_RE = re.compile(r"^[\x20-\x7e]+$")
_SAFE_ACTION_RE = re.compile(r"^[a-z0-9_]{1,64}$")
_SAFE_CALLBACK_VALUE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@/-]*$")
_MAX_CALLBACK_QUERY_ID_BYTES = 256
_MAX_CHAT_INSTANCE_BYTES = 256
_MAX_TEXT_BYTES = 8192
_MAX_NAME_BYTES = 256
class CallbackPayloadRejected(ValueError):
"""Raised when an update cannot be safely projected for forwarding."""
def load_controlled_correlation(
path: str = _CONTROLLED_IDENTITY_PATH,
) -> dict[str, str] | None:
"""Read one bounded non-secret deployment identity without following links."""
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError:
return None
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_size <= 0
or metadata.st_size > _CONTROLLED_IDENTITY_MAX_BYTES
):
return None
with os.fdopen(descriptor, "r", encoding="utf-8", closefd=False) as stream:
raw = stream.read(_CONTROLLED_IDENTITY_MAX_BYTES + 1)
except (OSError, UnicodeError):
return None
finally:
os.close(descriptor)
if len(raw.encode("utf-8")) > _CONTROLLED_IDENTITY_MAX_BYTES:
return None
try:
identity = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if not isinstance(identity, Mapping) or set(identity) != _CONTROLLED_IDENTITY_KEYS:
return None
if (
identity.get("schema_version") != _CONTROLLED_IDENTITY_SCHEMA
or identity.get("catalog_id") != _CONTROLLED_CATALOG_ID
):
return None
correlation = {"schema_version": _CONTROLLED_CORRELATION_SCHEMA}
for key in ("trace_id", "run_id", "work_item_id"):
value = identity.get(key)
if not isinstance(value, str) or not _SAFE_CORRELATION_ID_RE.fullmatch(value):
return None
correlation[key] = value
return correlation
@dataclass(frozen=True)
class CallbackForwardResult:
acknowledged: bool
reason: str
status_code: int | None = None
def callback_action(callback_data: str) -> str:
"""Return a redaction-safe action prefix, or ``unknown``."""
action = str(callback_data or "").split(":", 1)[0].strip()
return action if _SAFE_ACTION_RE.fullmatch(action) else "unknown"
def is_awoooi_read_callback(callback_data: str, *, max_data_bytes: int = 64) -> bool:
"""Accept only the explicit read-only action allowlist and Telegram size."""
raw = str(callback_data or "")
try:
encoded = raw.encode("utf-8")
except UnicodeError:
return False
if not raw or len(encoded) > max_data_bytes or not _SAFE_VALUE_RE.fullmatch(raw):
return False
action, separator, value = raw.partition(":")
return bool(
separator
and action in AWOOOI_READ_CALLBACK_ACTIONS
and _SAFE_CALLBACK_VALUE_RE.fullmatch(value)
)
def _bounded_string(value: Any, *, max_bytes: int, required: bool = False) -> str | None:
if value is None:
if required:
raise CallbackPayloadRejected("required_field_missing")
return None
text = str(value)
if required and not text:
raise CallbackPayloadRejected("required_field_empty")
if len(text.encode("utf-8")) > max_bytes:
raise CallbackPayloadRejected("field_too_large")
return text
def _copy_optional_strings(
source: Mapping[str, Any],
target: dict[str, Any],
fields: tuple[str, ...],
*,
max_bytes: int,
) -> None:
for field in fields:
value = _bounded_string(source.get(field), max_bytes=max_bytes)
if value is not None:
target[field] = value
def canonical_callback_update(
update: Mapping[str, Any],
*,
max_data_bytes: int = 64,
) -> dict[str, Any]:
"""Build the minimal callback Update accepted by the AWOOOI receiver."""
if not isinstance(update, Mapping):
raise CallbackPayloadRejected("update_not_mapping")
update_id = update.get("update_id")
if not isinstance(update_id, int) or isinstance(update_id, bool) or update_id < 0:
raise CallbackPayloadRejected("invalid_update_id")
query = update.get("callback_query")
if not isinstance(query, Mapping):
raise CallbackPayloadRejected("callback_query_missing")
data = _bounded_string(query.get("data"), max_bytes=max_data_bytes, required=True)
if data is None or not is_awoooi_read_callback(data, max_data_bytes=max_data_bytes):
raise CallbackPayloadRejected("action_not_allowlisted")
actor = query.get("from")
if not isinstance(actor, Mapping):
raise CallbackPayloadRejected("actor_missing")
actor_id = actor.get("id")
if not isinstance(actor_id, int) or isinstance(actor_id, bool):
raise CallbackPayloadRejected("invalid_actor_id")
actor_projection: dict[str, Any] = {"id": actor_id}
if isinstance(actor.get("is_bot"), bool):
actor_projection["is_bot"] = actor["is_bot"]
_copy_optional_strings(
actor,
actor_projection,
("first_name", "last_name", "username", "language_code"),
max_bytes=_MAX_NAME_BYTES,
)
query_projection: dict[str, Any] = {
"data": data,
"from": actor_projection,
"id": _bounded_string(
query.get("id"), max_bytes=_MAX_CALLBACK_QUERY_ID_BYTES, required=True
),
}
chat_instance = _bounded_string(
query.get("chat_instance"), max_bytes=_MAX_CHAT_INSTANCE_BYTES
)
if chat_instance is not None:
query_projection["chat_instance"] = chat_instance
message = query.get("message")
if isinstance(message, Mapping):
message_id = message.get("message_id")
if not isinstance(message_id, int) or isinstance(message_id, bool):
raise CallbackPayloadRejected("invalid_message_id")
message_projection: dict[str, Any] = {"message_id": message_id}
date = message.get("date")
if isinstance(date, int) and not isinstance(date, bool):
message_projection["date"] = date
text = _bounded_string(message.get("text"), max_bytes=_MAX_TEXT_BYTES)
if text is not None:
message_projection["text"] = text
chat = message.get("chat")
if not isinstance(chat, Mapping):
raise CallbackPayloadRejected("message_chat_missing")
chat_id = chat.get("id")
if not isinstance(chat_id, int) or isinstance(chat_id, bool):
raise CallbackPayloadRejected("invalid_chat_id")
chat_projection: dict[str, Any] = {"id": chat_id}
_copy_optional_strings(
chat,
chat_projection,
("type", "title", "username", "first_name", "last_name"),
max_bytes=_MAX_NAME_BYTES,
)
message_projection["chat"] = chat_projection
query_projection["message"] = message_projection
else:
inline_message_id = _bounded_string(
query.get("inline_message_id"),
max_bytes=_MAX_CALLBACK_QUERY_ID_BYTES,
)
if inline_message_id is None:
raise CallbackPayloadRejected("message_identity_missing")
query_projection["inline_message_id"] = inline_message_id
return {"callback_query": query_projection, "update_id": update_id}
def compact_json_body(payload: Mapping[str, Any], *, max_body_bytes: int) -> bytes:
body = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
if len(body) > max_body_bytes:
raise CallbackPayloadRejected("body_too_large")
return body
def callback_signature(signing_key: str, timestamp: str, body: bytes) -> str:
digest = hmac.new(
signing_key.encode("utf-8"),
timestamp.encode("ascii") + b"." + body,
hashlib.sha256,
).hexdigest()
return f"sha256={digest}"
def validate_forward_url(url: str, allowed_hosts: frozenset[str]) -> str:
parsed = urlsplit(str(url or "").strip())
hostname = (parsed.hostname or "").lower()
if (
parsed.scheme != "https"
or hostname not in allowed_hosts
or parsed.path != _EXPECTED_PATH
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or (parsed.port not in {None, 443})
):
raise ValueError("forward_url_not_allowlisted")
return parsed.geturl()
class TelegramCallbackForwarder:
"""Bounded HMAC client for the AWOOOI callback ingress."""
def __init__(
self,
*,
enabled: bool,
url: str,
signing_key: str,
allowed_hosts: frozenset[str] = frozenset({"awoooi.wooo.work"}),
timeout_seconds: float = 3.0,
max_body_bytes: int = 16_384,
max_data_bytes: int = 64,
controlled_identity_path: str = _CONTROLLED_IDENTITY_PATH,
) -> None:
self.enabled = bool(enabled)
self.url = str(url or "")
self._signing_key = str(signing_key or "")
self.allowed_hosts = frozenset(host.lower() for host in allowed_hosts if host)
self.timeout_seconds = min(max(float(timeout_seconds), 0.25), 10.0)
self.max_body_bytes = min(max(int(max_body_bytes), 1_024), 32_768)
self.max_data_bytes = min(max(int(max_data_bytes), 1), 64)
self.controlled_identity_path = str(controlled_identity_path)
async def forward(
self,
update: Mapping[str, Any],
*,
now: int | None = None,
client: httpx.AsyncClient | None = None,
) -> CallbackForwardResult:
if not self.enabled:
return CallbackForwardResult(False, "forwarding_disabled")
if not self._signing_key:
return CallbackForwardResult(False, "signing_key_unavailable")
try:
url = validate_forward_url(self.url, self.allowed_hosts)
payload = canonical_callback_update(
update,
max_data_bytes=self.max_data_bytes,
)
correlation = load_controlled_correlation(self.controlled_identity_path)
if correlation is not None:
payload["controlled_correlation"] = correlation
body = compact_json_body(payload, max_body_bytes=self.max_body_bytes)
except (CallbackPayloadRejected, ValueError, UnicodeError):
return CallbackForwardResult(False, "payload_or_destination_rejected")
timestamp = str(int(time.time()) if now is None else int(now))
headers = {
"Content-Type": "application/json",
"X-Telegram-Forward-Schema": "telegram_callback_forward_v1",
"X-Telegram-Forward-Timestamp": timestamp,
"X-Telegram-Forward-Signature": callback_signature(
self._signing_key,
timestamp,
body,
),
}
owns_client = client is None
active_client = client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=False,
)
try:
response = await active_client.post(url, content=body, headers=headers)
except httpx.TimeoutException:
return CallbackForwardResult(False, "upstream_timeout")
except httpx.HTTPError:
return CallbackForwardResult(False, "upstream_transport_error")
finally:
if owns_client:
await active_client.aclose()
if 200 <= response.status_code < 300:
return CallbackForwardResult(True, "forward_acknowledged", response.status_code)
return CallbackForwardResult(False, "upstream_non_2xx", response.status_code)
__all__ = [
"AWOOOI_READ_CALLBACK_ACTIONS",
"AWOOOI_READ_CALLBACK_PATTERN",
"CLAWBOT_APPROVAL_CALLBACK_PATTERN",
"CallbackForwardResult",
"CallbackPayloadRejected",
"TelegramCallbackForwarder",
"callback_action",
"callback_signature",
"canonical_callback_update",
"compact_json_body",
"is_awoooi_read_callback",
"load_controlled_correlation",
"validate_forward_url",
]

View File

@@ -0,0 +1,32 @@
{
"schema_version": "openclaw_callback_overlay_manifest_v1",
"source_repository": "gitea:wooo/clawbot-v5",
"source_commit": "ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9",
"overlay_revision_kind": "git_blob_of_callback_forwarder",
"overlay_revision": "8f367f8e7d6104c44b29bd5fa2d894db09ddf365",
"deployment_mode": "host_user_owned_read_only_compose_override_no_build_no_pull",
"runtime_source_mutation_allowed": false,
"runtime_compose_override_write_allowed": true,
"host_privilege_escalation_required": false,
"runtime_secret_read_allowed": false,
"payloads": [
{
"path": "app/bot/telegram.py",
"git_blob": "f9426b4b0ee3b685bbb155d7045c75b94a6bf0eb",
"sha256": "af9600f32305ae73184651a8d3437063d7c8d35e3c3eb0910a2e27ac874c9302"
},
{
"path": "app/core/config.py",
"git_blob": "08103e595dd82052ddbd55622f43ca77f8ca2fff",
"sha256": "e1b8b633f7b4780d3078d6c42a55e49d200255d3597a34e0cd2fd649b84eaea1"
},
{
"path": "app/services/telegram_callback_forwarder.py",
"git_blob": "8f367f8e7d6104c44b29bd5fa2d894db09ddf365",
"sha256": "8e8e0faf6322abb21d670ad9094f1805d7a4b3643da8fe88d74a8c9591066bf3"
}
],
"superseded_by": null,
"expiry": "after_clawbot_runtime_source_is_reconciled_to_an_immutable_gitea_image",
"exit_condition": "remove_overlay_only_after_clean_immutable_image_and_real_callback_replay_are_verified"
}

View File

@@ -16,8 +16,8 @@
vars:
catalog_id: ansible:188-openclaw-callback-forwarder
canonical_asset_id: service:openclaw:host188
expected_source_sha: ffc45311b6403b30cb6ae6e1aa83dae1a047e9c9
expected_manifest_sha256: dafeb467b744ee7e80dc043d51f9e5d7e6905a58103f1710d519b553b56c6e48
expected_source_sha: 8f367f8e7d6104c44b29bd5fa2d894db09ddf365
expected_manifest_sha256: 0e5edaf700d264b44cc0eae3b9429af642be0b1563a0bffa633942d30d2c2e1c
allowed_working_directories:
- /home/ollama/clawbot-v5
compose_project: clawbot
@@ -29,6 +29,11 @@
payload_root: "{{ managed_base }}/payloads/{{ expected_source_sha }}"
compose_override_path: "{{ openclaw_workdir }}/docker-compose.override.yml"
receipt_root: "{{ managed_base }}/receipts"
callback_identity_path: "{{ receipt_root }}/current-deployment.json"
callback_identity_container_path: >-
/run/awoooi/openclaw-callback-forwarder/current-deployment.json
superseded_compose_override_sha256s:
- 8f6b7e8a0d6746c1331e99910656e64deebf67c9582bab3e1d36f71bb7a253c0
rollback_root: "{{ managed_base }}/rollback"
trace_id: "{{ controlled_trace_id | default('') }}"
run_id: "{{ controlled_run_id | default('') }}"
@@ -60,7 +65,7 @@
{{ payload_root }}/app/services/telegram_callback_forwarder.py
container_path: /app/app/services/telegram_callback_forwarder.py
baseline_sha256: ""
target_sha256: 0d7693e34f63db4491d974f9f76a3a1c793d32cda193cb55ed3d8c86297b4fe3
target_sha256: 8e8e0faf6322abb21d670ad9094f1805d7a4b3643da8fe88d74a8c9591066bf3
managed_directories:
- path: "{{ managed_base }}"
mode: "0755"
@@ -428,6 +433,41 @@
check_mode: false
no_log: true
- name: Inspect existing controlled callback deployment identity
ansible.builtin.stat:
path: "{{ callback_identity_path }}"
follow: false
register: callback_identity_before
check_mode: false
changed_when: false
no_log: true
- name: Read existing controlled callback deployment identity for rollback
ansible.builtin.slurp:
src: "{{ callback_identity_path }}"
register: callback_identity_content_before
when: callback_identity_before.stat.exists | default(false)
check_mode: false
changed_when: false
no_log: true
- name: Fail closed on foreign callback deployment identity
ansible.builtin.assert:
that:
- >-
not (callback_identity_before.stat.exists | default(false))
or (
callback_identity_before.stat.isreg | default(false) | bool
and not (callback_identity_before.stat.islnk | default(false) | bool)
and (callback_identity_before.stat.size | default(4097) | int) <= 4096
and (callback_identity_before.stat.mode | default('')) == '0444'
and (callback_identity_before.stat.pw_name | default('')) == 'ollama'
and (callback_identity_before.stat.gr_name | default('')) == 'ollama'
)
fail_msg: openclaw_callback_deployment_identity_foreign_owner
changed_when: false
no_log: true
- name: Inspect existing user-owned overlay payloads
ansible.builtin.stat:
path: "{{ item.overlay_path }}"
@@ -481,6 +521,14 @@
- name: Define exact non-secret managed overlay documents
ansible.builtin.set_fact:
callback_identity_content: |
{
"schema_version": "openclaw_callback_deployment_identity_v1",
"catalog_id": "{{ catalog_id }}",
"trace_id": "{{ trace_id }}",
"run_id": "{{ run_id }}",
"work_item_id": "{{ work_item_id }}"
}
compose_override_content: |
services:
clawbot:
@@ -494,6 +542,7 @@
- "{{ payload_root }}/app/bot/telegram.py:/app/app/bot/telegram.py:ro"
- "{{ payload_root }}/app/core/config.py:/app/app/core/config.py:ro"
- "{{ payload_root }}/app/services/telegram_callback_forwarder.py:/app/app/services/telegram_callback_forwarder.py:ro"
- "{{ callback_identity_path }}:{{ callback_identity_container_path }}:ro"
changed_when: false
- name: Fail closed on foreign pre-existing managed artifacts
@@ -502,14 +551,27 @@
- >-
not (managed_artifacts_before.results[0].stat.exists | default(false))
or (
managed_artifact_contents_before.results
| selectattr('item.item', 'equalto', compose_override_path)
| map(attribute='content')
| map('b64decode')
| list
| first
| default('')
) == compose_override_content
(
(
managed_artifact_contents_before.results
| selectattr('item.item', 'equalto', compose_override_path)
| map(attribute='content')
| map('b64decode')
| list
| first
| default('')
) == compose_override_content
or (
managed_artifact_contents_before.results
| selectattr('item.item', 'equalto', compose_override_path)
| map(attribute='content')
| map('b64decode')
| list
| first
| default('')
| hash('sha256')
) in superseded_compose_override_sha256s
)
and (managed_artifacts_before.results[0].stat.isreg
| default(false) | bool)
and not (managed_artifacts_before.results[0].stat.islnk
@@ -768,6 +830,14 @@
mode: "0444"
content: "{{ compose_override_content }}"
- name: Install same-run non-secret callback deployment identity
ansible.builtin.copy:
dest: "{{ callback_identity_path }}"
owner: ollama
group: ollama
mode: "0444"
content: "{{ callback_identity_content }}"
- name: Mark the bounded service activation attempt
ansible.builtin.set_fact:
activation_attempted: true
@@ -1159,6 +1229,22 @@
changed_when: false
rescue:
- name: Remove callback identity created by the failed activation
ansible.builtin.file:
path: "{{ callback_identity_path }}"
state: absent
when: not (callback_identity_before.stat.exists | default(false))
- name: Restore exact callback identity after failed activation
ansible.builtin.copy:
dest: "{{ callback_identity_path }}"
owner: ollama
group: ollama
mode: "0444"
content: "{{ callback_identity_content_before.content | b64decode }}"
when: callback_identity_before.stat.exists | default(false)
no_log: true
- name: Remove managed Compose override after failed activation
ansible.builtin.file:
path: "{{ compose_override_path }}"