feat(host188): forward controlled callback correlation
This commit is contained in:
@@ -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\")\" = "
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 無法使用 — 預設改 ollama,OLLAMA_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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 }}"
|
||||
|
||||
Reference in New Issue
Block a user