mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
503 lines
19 KiB
Python
503 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Any, Dict, Optional
|
|
|
|
from connector.config import CommandClass
|
|
from connector.security_profile import ReplayGuard
|
|
from connector.transport_contract import (
|
|
CallbackContract,
|
|
CallbackError,
|
|
CallbackRecord,
|
|
CallbackState,
|
|
)
|
|
|
|
try:
|
|
from .audit import emit_audit_event
|
|
from .connector_installation_registry import (
|
|
ConnectorInstallationRegistry,
|
|
InstallationResolution,
|
|
get_connector_installation_registry,
|
|
)
|
|
from .connector_replay_lifecycle import ConnectorReplayLifecycle
|
|
except ImportError:
|
|
from services.audit import emit_audit_event # type: ignore
|
|
from services.connector_installation_registry import ( # type: ignore
|
|
ConnectorInstallationRegistry,
|
|
InstallationResolution,
|
|
get_connector_installation_registry,
|
|
)
|
|
from services.connector_replay_lifecycle import (
|
|
ConnectorReplayLifecycle, # type: ignore
|
|
)
|
|
|
|
try:
|
|
from .tenant_context import DEFAULT_TENANT_ID, get_current_tenant_id
|
|
except ImportError:
|
|
from services.tenant_context import ( # type: ignore
|
|
DEFAULT_TENANT_ID,
|
|
get_current_tenant_id,
|
|
)
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.services.connector_callback_contract")
|
|
|
|
DEFAULT_CALLBACK_TIMESTAMP_DRIFT_SEC = 300
|
|
_CALLBACK_SIGNING_VERSION = "v1"
|
|
|
|
|
|
class CallbackDecisionCode(str, Enum):
|
|
ACCEPT_PUBLIC = "cb_accept_public"
|
|
ACCEPT_RUN = "cb_accept_run"
|
|
ACCEPT_ADMIN = "cb_accept_admin"
|
|
REQUIRE_APPROVAL = "cb_require_approval"
|
|
REJECT_SIGNATURE = "cb_reject_signature"
|
|
REJECT_TIMESTAMP = "cb_reject_timestamp"
|
|
REJECT_PAYLOAD_HASH = "cb_reject_payload_hash"
|
|
REJECT_REPLAY = "cb_reject_replay"
|
|
REJECT_UNKNOWN_ACTION = "cb_reject_unknown_action"
|
|
REJECT_MISSING_INSTALLATION = "cb_reject_missing_installation"
|
|
REJECT_AMBIGUOUS_INSTALLATION = "cb_reject_ambiguous_installation"
|
|
REJECT_INACTIVE_INSTALLATION = "cb_reject_inactive_installation"
|
|
REJECT_STALE_TOKEN_REF = "cb_reject_stale_token_ref"
|
|
REJECT_TENANT_MISMATCH = "cb_reject_tenant_mismatch"
|
|
REJECT_POLICY_DENIED = "cb_reject_policy_denied"
|
|
REJECT_INVALID_ENVELOPE = "cb_reject_invalid_envelope"
|
|
|
|
|
|
@dataclass
|
|
class CallbackActorContext:
|
|
is_admin: bool = False
|
|
is_trusted: bool = False
|
|
user_id: str = ""
|
|
tenant_id: str = DEFAULT_TENANT_ID
|
|
|
|
|
|
@dataclass
|
|
class InteractiveCallbackEnvelope:
|
|
signature: str
|
|
timestamp: int
|
|
request_id: str
|
|
workspace_id: str
|
|
action_type: str
|
|
payload_hash: str
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: Dict[str, Any]) -> "InteractiveCallbackEnvelope":
|
|
try:
|
|
timestamp = int(payload.get("timestamp", 0))
|
|
except Exception as exc:
|
|
raise ValueError("timestamp must be integer") from exc
|
|
signature = str(payload.get("signature", "")).strip()
|
|
request_id = str(payload.get("request_id", "")).strip()
|
|
workspace_id = str(payload.get("workspace_id", "")).strip()
|
|
action_type = str(payload.get("action_type", "")).strip()
|
|
payload_hash = str(payload.get("payload_hash", "")).strip()
|
|
if not all(
|
|
[signature, request_id, workspace_id, action_type, payload_hash, timestamp]
|
|
):
|
|
raise ValueError("callback envelope fields must be non-empty")
|
|
return cls(
|
|
signature=signature,
|
|
timestamp=timestamp,
|
|
request_id=request_id,
|
|
workspace_id=workspace_id,
|
|
action_type=action_type,
|
|
payload_hash=payload_hash,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class CallbackDecision:
|
|
ok: bool
|
|
decision_code: str
|
|
command_class: str = ""
|
|
requires_approval: bool = False
|
|
callback_id: str = ""
|
|
installation_id: str = ""
|
|
message: str = ""
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"ok": self.ok,
|
|
"decision_code": self.decision_code,
|
|
"command_class": self.command_class,
|
|
"requires_approval": self.requires_approval,
|
|
"callback_id": self.callback_id,
|
|
"installation_id": self.installation_id,
|
|
"message": self.message,
|
|
}
|
|
|
|
|
|
class ConnectorCallbackContract:
|
|
"""Platform-agnostic interactive callback validation and policy gate."""
|
|
|
|
def __init__(
|
|
self,
|
|
signing_secret: str,
|
|
*,
|
|
installation_registry: Optional[ConnectorInstallationRegistry] = None,
|
|
replay_guard: Optional[ReplayGuard] = None,
|
|
replay_lifecycle: Optional[ConnectorReplayLifecycle] = None,
|
|
callback_contract: Optional[CallbackContract] = None,
|
|
action_policy_map: Optional[Dict[str, str]] = None,
|
|
timestamp_drift_sec: int = DEFAULT_CALLBACK_TIMESTAMP_DRIFT_SEC,
|
|
):
|
|
self._signing_secret = str(signing_secret or "")
|
|
self._installation_registry = (
|
|
installation_registry or get_connector_installation_registry()
|
|
)
|
|
self._replay_guard = replay_guard or ReplayGuard(
|
|
window_sec=300, max_entries=5000
|
|
)
|
|
self._replay_lifecycle = replay_lifecycle or ConnectorReplayLifecycle(
|
|
ttl_sec=300, max_entries=5000
|
|
)
|
|
self._callback_contract = callback_contract or CallbackContract()
|
|
self._action_policy_map = dict(action_policy_map or {})
|
|
self._timestamp_drift_sec = max(1, int(timestamp_drift_sec))
|
|
|
|
@staticmethod
|
|
def compute_payload_hash(payload: Dict[str, Any]) -> str:
|
|
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
def compute_signature(self, envelope: InteractiveCallbackEnvelope) -> str:
|
|
base = (
|
|
f"{_CALLBACK_SIGNING_VERSION}:{envelope.timestamp}:{envelope.request_id}:"
|
|
f"{envelope.workspace_id}:{envelope.action_type}:{envelope.payload_hash}"
|
|
)
|
|
return hmac.new(
|
|
self._signing_secret.encode("utf-8"),
|
|
base.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
def build_envelope(
|
|
self,
|
|
*,
|
|
request_id: str,
|
|
workspace_id: str,
|
|
action_type: str,
|
|
payload: Dict[str, Any],
|
|
timestamp: Optional[int] = None,
|
|
) -> InteractiveCallbackEnvelope:
|
|
ts = int(timestamp or time.time())
|
|
payload_hash = self.compute_payload_hash(payload)
|
|
unsigned = InteractiveCallbackEnvelope(
|
|
signature="",
|
|
timestamp=ts,
|
|
request_id=request_id,
|
|
workspace_id=workspace_id,
|
|
action_type=action_type,
|
|
payload_hash=payload_hash,
|
|
)
|
|
unsigned.signature = self.compute_signature(unsigned)
|
|
return unsigned
|
|
|
|
def _map_installation_reject(
|
|
self, resolution: InstallationResolution
|
|
) -> CallbackDecision:
|
|
reason = resolution.reject_reason
|
|
if reason == "missing_binding":
|
|
code = CallbackDecisionCode.REJECT_MISSING_INSTALLATION.value
|
|
elif reason == "ambiguous_binding":
|
|
code = CallbackDecisionCode.REJECT_AMBIGUOUS_INSTALLATION.value
|
|
elif reason == "inactive_binding":
|
|
code = CallbackDecisionCode.REJECT_INACTIVE_INSTALLATION.value
|
|
elif reason.startswith("stale_token_ref"):
|
|
code = CallbackDecisionCode.REJECT_STALE_TOKEN_REF.value
|
|
elif reason == "tenant_mismatch":
|
|
code = CallbackDecisionCode.REJECT_TENANT_MISMATCH.value
|
|
else:
|
|
code = CallbackDecisionCode.REJECT_INVALID_ENVELOPE.value
|
|
return CallbackDecision(ok=False, decision_code=code, message=reason)
|
|
|
|
def _resolve_action_policy(self, action_type: str) -> Optional[str]:
|
|
if action_type in self._action_policy_map:
|
|
return self._action_policy_map[action_type]
|
|
for key, value in self._action_policy_map.items():
|
|
if key.endswith("*") and action_type.startswith(key[:-1]):
|
|
return value
|
|
return None
|
|
|
|
def _audit_decision(
|
|
self,
|
|
*,
|
|
platform: str,
|
|
envelope: Optional[InteractiveCallbackEnvelope],
|
|
decision: CallbackDecision,
|
|
) -> None:
|
|
details = {
|
|
"platform": platform,
|
|
"decision_code": decision.decision_code,
|
|
"command_class": decision.command_class,
|
|
"requires_approval": decision.requires_approval,
|
|
"callback_id": decision.callback_id,
|
|
"installation_id": decision.installation_id,
|
|
}
|
|
if envelope is not None:
|
|
details.update(
|
|
{
|
|
"request_id": envelope.request_id,
|
|
"workspace_id": envelope.workspace_id,
|
|
"action_type": envelope.action_type,
|
|
}
|
|
)
|
|
emit_audit_event(
|
|
action="connector.callback.evaluate",
|
|
target=decision.installation_id or "connector_callback",
|
|
outcome="allow" if decision.ok else "deny",
|
|
status_code=200 if decision.ok else 403,
|
|
details=details,
|
|
)
|
|
|
|
@staticmethod
|
|
def _request_lifecycle_key(envelope: InteractiveCallbackEnvelope) -> str:
|
|
return (
|
|
f"interactive:{envelope.workspace_id}:"
|
|
f"{envelope.action_type}:{envelope.request_id}"
|
|
)
|
|
|
|
def evaluate(
|
|
self,
|
|
*,
|
|
platform: str,
|
|
envelope_dict: Dict[str, Any],
|
|
payload: Dict[str, Any],
|
|
actor: CallbackActorContext,
|
|
) -> CallbackDecision:
|
|
try:
|
|
envelope = InteractiveCallbackEnvelope.from_dict(envelope_dict)
|
|
except ValueError as exc:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_INVALID_ENVELOPE.value,
|
|
message=str(exc),
|
|
)
|
|
self._audit_decision(platform=platform, envelope=None, decision=decision)
|
|
return decision
|
|
|
|
now = int(time.time())
|
|
if abs(now - envelope.timestamp) > self._timestamp_drift_sec:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_TIMESTAMP.value,
|
|
message="timestamp_out_of_window",
|
|
)
|
|
self._audit_decision(
|
|
platform=platform, envelope=envelope, decision=decision
|
|
)
|
|
return decision
|
|
|
|
expected_hash = self.compute_payload_hash(payload)
|
|
if not hmac.compare_digest(expected_hash, envelope.payload_hash):
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_PAYLOAD_HASH.value,
|
|
message="payload_hash_mismatch",
|
|
)
|
|
self._audit_decision(
|
|
platform=platform, envelope=envelope, decision=decision
|
|
)
|
|
return decision
|
|
|
|
# CRITICAL: interactive callback signatures must remain canonical and constant-time.
|
|
expected_signature = self.compute_signature(envelope)
|
|
if not self._signing_secret or not hmac.compare_digest(
|
|
expected_signature, envelope.signature
|
|
):
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_SIGNATURE.value,
|
|
message="signature_mismatch",
|
|
)
|
|
self._audit_decision(
|
|
platform=platform, envelope=envelope, decision=decision
|
|
)
|
|
return decision
|
|
|
|
lifecycle_key = self._request_lifecycle_key(envelope)
|
|
claim = self._replay_lifecycle.claim(
|
|
lifecycle_key,
|
|
metadata={
|
|
"workspace_id": envelope.workspace_id,
|
|
"action_type": envelope.action_type,
|
|
},
|
|
)
|
|
if not claim.accepted:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_REPLAY.value,
|
|
message=claim.code,
|
|
)
|
|
self._audit_decision(
|
|
platform=platform, envelope=envelope, decision=decision
|
|
)
|
|
return decision
|
|
|
|
resolution = self._installation_registry.resolve_installation(
|
|
platform,
|
|
envelope.workspace_id,
|
|
tenant_id=(actor.tenant_id or get_current_tenant_id()),
|
|
)
|
|
if not resolution.ok or resolution.installation is None:
|
|
decision = self._map_installation_reject(resolution)
|
|
self._replay_lifecycle.fail_terminal(
|
|
lifecycle_key, reason=decision.decision_code
|
|
)
|
|
self._audit_decision(
|
|
platform=platform, envelope=envelope, decision=decision
|
|
)
|
|
return decision
|
|
|
|
command_class = self._resolve_action_policy(envelope.action_type)
|
|
if command_class is None:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_UNKNOWN_ACTION.value,
|
|
installation_id=resolution.installation.installation_id,
|
|
message="unknown_action_type",
|
|
)
|
|
self._replay_lifecycle.fail_terminal(
|
|
lifecycle_key, reason=decision.decision_code
|
|
)
|
|
self._audit_decision(
|
|
platform=platform, envelope=envelope, decision=decision
|
|
)
|
|
return decision
|
|
|
|
record = self._callback_contract.create(
|
|
idempotency_key=envelope.request_id,
|
|
payload=payload,
|
|
metadata={
|
|
"platform": platform,
|
|
"workspace_id": envelope.workspace_id,
|
|
"action_type": envelope.action_type,
|
|
"installation_id": resolution.installation.installation_id,
|
|
},
|
|
)
|
|
|
|
if command_class == CommandClass.PUBLIC.value:
|
|
decision = CallbackDecision(
|
|
ok=True,
|
|
decision_code=CallbackDecisionCode.ACCEPT_PUBLIC.value,
|
|
command_class=command_class,
|
|
callback_id=record.callback_id,
|
|
installation_id=resolution.installation.installation_id,
|
|
)
|
|
elif command_class == CommandClass.RUN.value:
|
|
if actor.is_admin or actor.is_trusted:
|
|
decision = CallbackDecision(
|
|
ok=True,
|
|
decision_code=CallbackDecisionCode.ACCEPT_RUN.value,
|
|
command_class=command_class,
|
|
callback_id=record.callback_id,
|
|
installation_id=resolution.installation.installation_id,
|
|
)
|
|
else:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REQUIRE_APPROVAL.value,
|
|
command_class=command_class,
|
|
requires_approval=True,
|
|
callback_id=record.callback_id,
|
|
installation_id=resolution.installation.installation_id,
|
|
)
|
|
elif command_class == CommandClass.ADMIN.value:
|
|
if actor.is_admin:
|
|
decision = CallbackDecision(
|
|
ok=True,
|
|
decision_code=CallbackDecisionCode.ACCEPT_ADMIN.value,
|
|
command_class=command_class,
|
|
callback_id=record.callback_id,
|
|
installation_id=resolution.installation.installation_id,
|
|
)
|
|
else:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_POLICY_DENIED.value,
|
|
command_class=command_class,
|
|
callback_id=record.callback_id,
|
|
installation_id=resolution.installation.installation_id,
|
|
message="admin_required",
|
|
)
|
|
self._replay_lifecycle.fail_terminal(
|
|
lifecycle_key, reason=decision.decision_code
|
|
)
|
|
else:
|
|
decision = CallbackDecision(
|
|
ok=False,
|
|
decision_code=CallbackDecisionCode.REJECT_UNKNOWN_ACTION.value,
|
|
installation_id=resolution.installation.installation_id,
|
|
message="unsupported_command_class",
|
|
)
|
|
self._replay_lifecycle.fail_terminal(
|
|
lifecycle_key, reason=decision.decision_code
|
|
)
|
|
self._audit_decision(platform=platform, envelope=envelope, decision=decision)
|
|
return decision
|
|
|
|
def get_record(self, request_id: str) -> Optional[CallbackRecord]:
|
|
return self._callback_contract.get_by_idempotency_key(str(request_id).strip())
|
|
|
|
def acknowledge_request(self, request_id: str) -> CallbackRecord:
|
|
record = self.get_record(request_id)
|
|
if record is None:
|
|
raise CallbackError(f"Callback not found for request_id={request_id}")
|
|
return self._callback_contract.acknowledge(record.callback_id)
|
|
|
|
def complete_request(self, request_id: str) -> CallbackRecord:
|
|
record = self.get_record(request_id)
|
|
if record is None:
|
|
raise CallbackError(f"Callback not found for request_id={request_id}")
|
|
delivered = self._callback_contract.deliver(record.callback_id)
|
|
workspace_id = str(delivered.metadata.get("workspace_id", "") or "")
|
|
action_type = str(delivered.metadata.get("action_type", "") or "")
|
|
if workspace_id and action_type:
|
|
self._replay_lifecycle.commit_success(
|
|
f"interactive:{workspace_id}:{action_type}:{request_id}",
|
|
reason="completed",
|
|
)
|
|
return delivered
|
|
|
|
def release_request_retryable(
|
|
self, request_id: str, *, reason: str = "retryable_failure"
|
|
) -> None:
|
|
record = self.get_record(request_id)
|
|
if record is None:
|
|
return
|
|
if record.state != CallbackState.DELIVERED.value:
|
|
# IMPORTANT: retryable release must make CallbackContract.create()
|
|
# allocate a fresh record on the next accepted claim. Keeping the
|
|
# old acknowledged/pending record would fail the second ack path.
|
|
record.state = CallbackState.FAILED.value
|
|
workspace_id = str(record.metadata.get("workspace_id", "") or "")
|
|
action_type = str(record.metadata.get("action_type", "") or "")
|
|
if workspace_id and action_type:
|
|
self._replay_lifecycle.release_retryable(
|
|
f"interactive:{workspace_id}:{action_type}:{request_id}",
|
|
reason=reason,
|
|
)
|
|
|
|
def fail_request_terminal(
|
|
self, request_id: str, *, reason: str = "terminal_failure"
|
|
) -> None:
|
|
record = self.get_record(request_id)
|
|
if record is None:
|
|
return
|
|
if record.state != CallbackState.DELIVERED.value:
|
|
record.state = CallbackState.FAILED.value
|
|
workspace_id = str(record.metadata.get("workspace_id", "") or "")
|
|
action_type = str(record.metadata.get("action_type", "") or "")
|
|
if workspace_id and action_type:
|
|
self._replay_lifecycle.fail_terminal(
|
|
f"interactive:{workspace_id}:{action_type}:{request_id}",
|
|
reason=reason,
|
|
)
|