Files
awoooi/apps/api/src/services/provider_proxy.py
Your Name 8629ac709b
Some checks failed
run-migration / migrate (push) Failing after 59s
Code Review / ai-code-review (push) Successful in 1m8s
Type Sync Check / check-type-sync (push) Successful in 2m27s
feat(awooop): Phase 1-8 完整實作 — AwoooP Agent Platform 六平面架構
## 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>
2026-05-04 19:31:53 +08:00

241 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 writeauto-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 AdapterADR-115 D3
職責:
1. 驗證 project 存在且不是 legacy mode
2. upsert platform_subjectauto-provisioning
3. 生成 trace_idW3C 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_subjectauto-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,
},
)