## Phase 1-3: Control Plane + Contract System - awooop_phase1_control_plane_2026-05-04.sql: 12 張核心表 + RLS - awooop_phase1_batch1_rls_2026-05-04.sql: 全部 FORCE RLS + GRANT - packages/awooop-contracts/: 六合約 JSON Schema + golden fixtures - src/models/awooop_contracts.py: Pydantic v2 contract models(extra=forbid) - src/repositories/contract_repository.py: contract lifecycle(draft→published→active) - src/services/contract_service.py: HMAC publish sig + Redis multi-sig activate - src/services/schema_validator.py: LLM output validator(retry×3, E-SCHEMA-001) ## Phase 2: Tenant Isolation - awooop_phase2_budget_ledger_2026-05-04.sql: budget_ledger + RLS - src/services/budget_service.py: Token Budget Hard Kill 三層防線 - src/core/context.py: PROJECT_ID ContextVar(31 background loop 自動繼承) - src/db/base.py + models.py: project_id 欄位 + RLS set_config 注入 - src/hermes/nl_gateway.py: project_id Redis key 前綴(Phase A 雙寫) - src/services/anomaly_counter.py: per-project 改造(Phase A fallback) ## Phase 4: Platform Shell in Shadow Mode - awooop_phase4_run_state_2026-05-04.sql: run_state + step_journal + idempotency - src/services/run_state_machine.py: 8-state FSM + SKIP LOCKED + stale reaper - src/services/platform_runtime.py: UUID v7 + W3C trace_id + shadow_execute - src/services/audit_sink.py: PII/secret redaction 9 patterns - src/api/v1/platform/runs.py: POST/GET /v1/platform/runs(Router→Service 架構) - src/workers/platform_worker.py: SKIP LOCKED worker + heartbeat + reaper loop - src/main.py: platform router + lifespan worker start/stop ## Phase 5: MCP Gateway 五閘門 - awooop_phase5_mcp_gateway_2026-05-04.sql: 4 表 + RLS - src/plugins/mcp/gateway.py: McpGateway(Gate 1~5, E-MCP-GATE-001~009) - src/plugins/mcp/redaction_middleware.py: 雙層 redaction + 16K 截斷 - src/plugins/mcp/registry.py: __provider name mangling(ADR-116) - src/plugins/mcp/credential_resolver.py: k8s secret ref 解析 - tests/test_mcp_credential_isolation.py: 10 個迴歸測試(secret leak 防再現) ## Phase 6-8: EwoooC + Channel Hub + Approval Token - awooop_phase6_ewoooc_onboarding_2026-05-04.sql: ewoooc tenant + 4 read-only MCP tools - awooop_phase7_channel_hub_2026-05-04.sql: conversation_event + outbound_message - src/services/provider_proxy.py: ProviderProxy + PlatformEnvelope(ADR-115) - src/services/channel_hub.py: Telegram inbound mirror + Progressive Feedback(30s) - src/services/awooop_approval_token.py: HS256 + jti NX replay 防護 + suggest mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
9.6 KiB
Python
241 lines
9.6 KiB
Python
"""
|
||
Provider Proxy Adapter — EwoooC AwoooP Envelope 注入
|
||
=====================================================
|
||
AwoooP Phase 6: ADR-115 D3
|
||
2026-05-04 ogt + Claude Sonnet 4.6
|
||
|
||
功能:
|
||
EwoooC(或任何外部 tenant)的請求在進入 AwoooP 前,
|
||
必須注入完整的 platform envelope,確保:
|
||
- project_id 正確(budget/audit/RLS 有效)
|
||
- agent_id 存在(Gate 2 通過)
|
||
- trace_id / run_id 有 W3C traceparent format
|
||
- platform_subject_id 已建立(channel user 身份映射)
|
||
|
||
使用方式:
|
||
from src.services.provider_proxy import ProviderProxy
|
||
|
||
proxy = ProviderProxy(project_id="ewoooc", db=db)
|
||
envelope = await proxy.build_envelope(
|
||
agent_id="openclaw-biz",
|
||
channel_type="telegram",
|
||
channel_user_id="123456789",
|
||
channel_chat_id="123456789",
|
||
)
|
||
# envelope 可直接作為 GatewayContext 的初始化參數
|
||
|
||
設計原則(ADR-115 D3):
|
||
- Proxy 只做 envelope 注入(<1ms),不做額外複雜 IO
|
||
- platform_subject upsert 是唯一 DB write(auto-provisioning)
|
||
- run_id 由 platform_runtime.create_run() 分配,Proxy 不自行生成
|
||
- 每個 tenant 有獨立的 budget partition 和 RLS 隔離
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import os
|
||
import re
|
||
import struct
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
import structlog
|
||
from sqlalchemy import select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.db.awooop_models import AwoooPPlatformSubject, AwoooPProject
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Platform Envelope
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class PlatformEnvelope:
|
||
"""
|
||
AwoooP Platform Envelope — 每個 EwoooC 請求注入的 metadata。
|
||
|
||
下游(Gateway / Budget / Audit)都依賴這個 envelope。
|
||
"""
|
||
project_id: str
|
||
agent_id: str
|
||
trace_id: str # W3C traceparent
|
||
platform_subject_id: str # "{project_id}:{channel_type}:{channel_user_id}"
|
||
channel_type: str
|
||
channel_user_id: str
|
||
channel_chat_id: str | None = None
|
||
run_id: UUID | None = None # 由 create_run() 填入
|
||
policy_revision_id: str | None = None # active policy contract revision
|
||
tags: dict[str, Any] = field(default_factory=dict)
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"project_id": self.project_id,
|
||
"agent_id": self.agent_id,
|
||
"trace_id": self.trace_id,
|
||
"platform_subject_id": self.platform_subject_id,
|
||
"channel_type": self.channel_type,
|
||
"channel_user_id": self.channel_user_id,
|
||
"channel_chat_id": self.channel_chat_id,
|
||
"run_id": str(self.run_id) if self.run_id else None,
|
||
"policy_revision_id": self.policy_revision_id,
|
||
}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# W3C traceparent 生成
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _new_trace_id() -> str:
|
||
"""生成 W3C traceparent 格式 trace_id。格式:00-{32hex}-{16hex}-01"""
|
||
trace_id = uuid.uuid4().hex # 32 hex chars = 128 bits
|
||
span_id = uuid.uuid4().hex[:16] # 16 hex chars = 64 bits
|
||
return f"00-{trace_id}-{span_id}-01"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# platform_subject_id 格式
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def build_platform_subject_id(project_id: str, channel_type: str, channel_user_id: str) -> str:
|
||
"""
|
||
格式:{project_id}:{channel_type}:{channel_user_id}
|
||
例:ewoooc:telegram:123456789
|
||
"""
|
||
return f"{project_id}:{channel_type}:{channel_user_id}"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# ProviderProxy
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ProviderProxy:
|
||
"""
|
||
AwoooP Provider Proxy Adapter(ADR-115 D3)。
|
||
|
||
職責:
|
||
1. 驗證 project 存在且不是 legacy mode
|
||
2. upsert platform_subject(auto-provisioning)
|
||
3. 生成 trace_id(W3C traceparent)
|
||
4. 返回 PlatformEnvelope 供下游使用
|
||
"""
|
||
|
||
def __init__(self, project_id: str, db: AsyncSession) -> None:
|
||
self.project_id = project_id
|
||
self._db = db
|
||
|
||
async def build_envelope(
|
||
self,
|
||
*,
|
||
agent_id: str,
|
||
channel_type: str,
|
||
channel_user_id: str,
|
||
channel_chat_id: str | None = None,
|
||
display_name: str | None = None,
|
||
extra_tags: dict[str, Any] | None = None,
|
||
) -> PlatformEnvelope:
|
||
"""
|
||
建立 PlatformEnvelope:
|
||
1. 驗證 project_id(不是 legacy mode)
|
||
2. upsert platform_subject(auto-provisioning)
|
||
3. 生成 trace_id
|
||
4. 返回 envelope
|
||
"""
|
||
await self._validate_project()
|
||
await self._upsert_platform_subject(
|
||
channel_type=channel_type,
|
||
channel_user_id=channel_user_id,
|
||
channel_chat_id=channel_chat_id,
|
||
display_name=display_name,
|
||
)
|
||
|
||
platform_subject_id = build_platform_subject_id(
|
||
self.project_id, channel_type, channel_user_id
|
||
)
|
||
trace_id = _new_trace_id()
|
||
|
||
logger.info(
|
||
"provider_proxy_envelope_built",
|
||
project_id=self.project_id,
|
||
agent_id=agent_id,
|
||
channel_type=channel_type,
|
||
platform_subject_id=platform_subject_id,
|
||
trace_id=trace_id[:32] + "...", # 只 log 前 32 字元
|
||
)
|
||
|
||
return PlatformEnvelope(
|
||
project_id=self.project_id,
|
||
agent_id=agent_id,
|
||
trace_id=trace_id,
|
||
platform_subject_id=platform_subject_id,
|
||
channel_type=channel_type,
|
||
channel_user_id=channel_user_id,
|
||
channel_chat_id=channel_chat_id,
|
||
tags=extra_tags or {},
|
||
)
|
||
|
||
async def _validate_project(self) -> None:
|
||
"""project 必須存在且不是 legacy_awoooi_default mode"""
|
||
result = await self._db.execute(
|
||
select(AwoooPProject).where(
|
||
AwoooPProject.project_id == self.project_id,
|
||
AwoooPProject.migration_mode != "legacy_awoooi_default",
|
||
)
|
||
)
|
||
project = result.scalar_one_or_none()
|
||
if project is None:
|
||
raise ValueError(
|
||
f"project '{self.project_id}' 不存在或 migration_mode=legacy_awoooi_default"
|
||
"(EwoooC 接入需要至少 migration_mode='shadow')"
|
||
)
|
||
|
||
async def _upsert_platform_subject(
|
||
self,
|
||
*,
|
||
channel_type: str,
|
||
channel_user_id: str,
|
||
channel_chat_id: str | None,
|
||
display_name: str | None,
|
||
) -> None:
|
||
"""
|
||
Auto-provisioning:第一次看到這個 channel user 就建立 platform_subject。
|
||
後續請求更新 last_seen_at。
|
||
"""
|
||
platform_subject_id = build_platform_subject_id(
|
||
self.project_id, channel_type, channel_user_id
|
||
)
|
||
now = datetime.now(timezone.utc)
|
||
|
||
await self._db.execute(
|
||
text("""
|
||
INSERT INTO awooop_platform_subjects (
|
||
project_id, channel_type, channel_user_id, channel_chat_id,
|
||
platform_subject_id, display_name, roles,
|
||
first_seen_at, last_seen_at
|
||
) VALUES (
|
||
:project_id, :channel_type, :channel_user_id, :channel_chat_id,
|
||
:platform_subject_id, :display_name, '["viewer"]'::jsonb,
|
||
:now, :now
|
||
)
|
||
ON CONFLICT (project_id, channel_type, channel_user_id) DO UPDATE SET
|
||
last_seen_at = :now,
|
||
channel_chat_id = COALESCE(EXCLUDED.channel_chat_id, awooop_platform_subjects.channel_chat_id),
|
||
display_name = COALESCE(EXCLUDED.display_name, awooop_platform_subjects.display_name)
|
||
"""),
|
||
{
|
||
"project_id": self.project_id,
|
||
"channel_type": channel_type,
|
||
"channel_user_id": channel_user_id,
|
||
"channel_chat_id": channel_chat_id,
|
||
"platform_subject_id": platform_subject_id,
|
||
"display_name": display_name,
|
||
"now": now,
|
||
},
|
||
)
|