feat(autonomous-release): add native session adapters

This commit is contained in:
chatgpt-final-release-writer
2026-07-26 04:43:36 +08:00
parent fe49f7e47f
commit 0440ae4a65
@@ -0,0 +1,246 @@
"""Candidate-only OpenClaw and Codex native session adapter contracts."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Protocol, Sequence
import uuid
from .errors import StateConflict
class NativeProvider(Protocol):
simulation: bool
def spawn(
self, request: Mapping[str, Any], credential_reference: str
) -> Mapping[str, Any]: ...
def status(
self, provider_session_id: str, credential_reference: str
) -> Mapping[str, Any]: ...
def list_tree(
self, parent_session_id: str, credential_reference: str
) -> Sequence[Mapping[str, Any]]: ...
def send_text(
self,
provider_session_id: str,
payload: Mapping[str, Any],
credential_reference: str,
) -> Mapping[str, Any]: ...
def interrupt(self, provider_session_id: str, credential_reference: str) -> None: ...
def close(self, provider_session_id: str, credential_reference: str) -> None: ...
@dataclass(frozen=True, slots=True)
class NativeSession:
provider: str
broker_session_id: str
provider_session_id: str
task_id: str
parent_session_id: str
recipient_binding: Mapping[str, str]
@dataclass(frozen=True, slots=True)
class SessionVerification:
broker_session_verified: bool
openclaw_native_child_session_verified: bool
codex_child_session_verified: bool
simulation: bool
@dataclass(frozen=True, slots=True)
class TextResult:
content_type: str
encoding: str
image_only: bool
task_id: str
message_id: str
receipt: str
recipient_binding: Mapping[str, str]
secrets_included: bool
class CandidateNativeAdapter:
provider = ""
required_reference = ""
def __init__(self, client: NativeProvider, credential_reference: str) -> None:
if credential_reference != self.required_reference:
raise StateConflict(
"CANDIDATE_CREDENTIAL_REFERENCE_MISMATCH",
"adapter requires its exact Candidate credential reference",
)
self.client = client
self.credential_reference = credential_reference
@staticmethod
def _recipient(value: Mapping[str, Any]) -> dict[str, str]:
recipient_id = value.get("recipient_id")
if (
not isinstance(recipient_id, str)
or not recipient_id
or isinstance(value.get("recipients"), (list, tuple))
):
raise StateConflict(
"RECIPIENT_BINDING_REQUIRED",
"one trusted TaskPlan recipient binding is required",
)
return {str(key): str(item) for key, item in value.items()}
def spawn(
self,
task_id: str,
parent_session_id: str,
recipient_binding: Mapping[str, Any],
) -> NativeSession:
if not task_id or not parent_session_id:
raise StateConflict("NATIVE_SESSION_BINDING_REQUIRED", "task and parent are required")
recipient = self._recipient(recipient_binding)
response = self.client.spawn(
{
"task_id": task_id,
"parent_session_id": parent_session_id,
"recipient_binding": recipient,
"candidate_only": True,
"production_session_access": False,
},
self.credential_reference,
)
provider_session_id = response.get("provider_session_id")
if (
not isinstance(provider_session_id, str)
or not provider_session_id
or response.get("task_id") != task_id
or response.get("parent_session_id") != parent_session_id
or response.get("candidate_only") is not True
or response.get("production_session_access") is not False
):
raise StateConflict(
"NATIVE_SESSION_PROVISIONING_INVALID",
"provider response is not bound to this Candidate task",
)
broker_id = f"broker-{uuid.uuid4()}"
if broker_id == provider_session_id:
raise StateConflict("SESSION_ID_COLLISION", "broker and provider IDs must differ")
return NativeSession(
provider=self.provider,
broker_session_id=broker_id,
provider_session_id=provider_session_id,
task_id=task_id,
parent_session_id=parent_session_id,
recipient_binding=recipient,
)
def verify(self, session: NativeSession) -> SessionVerification:
self._validate_session(session)
status = self.client.status(session.provider_session_id, self.credential_reference)
tree = self.client.list_tree(session.parent_session_id, self.credential_reference)
status_valid = (
status.get("provider_session_id") == session.provider_session_id
and status.get("state") in {"PROVISIONED", "ACTIVE", "RUNNING"}
)
tree_valid = any(
item.get("provider_session_id") == session.provider_session_id
and item.get("parent_session_id") == session.parent_session_id
for item in tree
)
if not status_valid or not tree_valid:
raise StateConflict(
"NATIVE_SESSION_VERIFICATION_FAILED",
"provider status or parent/child tree is inconsistent",
)
simulated = bool(getattr(self.client, "simulation", True))
return SessionVerification(
broker_session_verified=True,
openclaw_native_child_session_verified=(
self.provider == "openclaw" and not simulated
),
codex_child_session_verified=(self.provider == "codex" and not simulated),
simulation=simulated,
)
def send_text(self, session: NativeSession, text: str) -> TextResult:
self._validate_session(session)
if not isinstance(text, str) or not text:
raise StateConflict("TEXT_RESULT_REQUIRED", "non-empty text is required")
response = self.client.send_text(
session.provider_session_id,
{
"task_id": session.task_id,
"content_type": "text/plain",
"encoding": "utf-8",
"image_only": False,
"text": text,
"recipient_binding": dict(session.recipient_binding),
"secrets_included": False,
},
self.credential_reference,
)
required = {
"content_type": "text/plain",
"encoding": "utf-8",
"image_only": False,
"task_id": session.task_id,
"recipient_binding": dict(session.recipient_binding),
"secrets_included": False,
}
if any(response.get(key) != value for key, value in required.items()):
raise StateConflict("TEXT_RESULT_CONTRACT_INVALID", "text result binding is invalid")
message_id = response.get("message_id")
receipt = response.get("receipt")
if not isinstance(message_id, str) or not message_id or not isinstance(receipt, str) or not receipt:
raise StateConflict("TEXT_RESULT_RECEIPT_REQUIRED", "message and receipt are required")
return TextResult(
content_type="text/plain",
encoding="utf-8",
image_only=False,
task_id=session.task_id,
message_id=message_id,
receipt=receipt,
recipient_binding=dict(session.recipient_binding),
secrets_included=False,
)
def status(self, session: NativeSession) -> str:
self._validate_session(session)
response = self.client.status(session.provider_session_id, self.credential_reference)
if response.get("provider_session_id") != session.provider_session_id:
raise StateConflict("NATIVE_SESSION_STATUS_INVALID", "provider session ID changed")
state = response.get("state")
if not isinstance(state, str) or not state:
raise StateConflict("NATIVE_SESSION_STATUS_INVALID", "provider state is missing")
return state
def interrupt(self, session: NativeSession) -> None:
self._validate_session(session)
self.client.interrupt(session.provider_session_id, self.credential_reference)
if self.status(session) != "INTERRUPTED":
raise StateConflict("NATIVE_SESSION_INTERRUPT_FAILED", "interrupt was not observed")
def close(self, session: NativeSession) -> None:
self._validate_session(session)
self.client.close(session.provider_session_id, self.credential_reference)
if self.status(session) != "CLOSED":
raise StateConflict("NATIVE_SESSION_CLOSE_FAILED", "close was not observed")
def _validate_session(self, session: NativeSession) -> None:
if session.provider != self.provider:
raise StateConflict("NATIVE_SESSION_PROVIDER_MISMATCH", "session provider differs")
if session.broker_session_id == session.provider_session_id:
raise StateConflict("SESSION_ID_COLLISION", "broker and provider IDs must differ")
class OpenClawNativeAdapter(CandidateNativeAdapter):
provider = "openclaw"
required_reference = "systemd-credential:openclaw-gateway-candidate"
class CodexNativeAdapter(CandidateNativeAdapter):
provider = "codex"
required_reference = "systemd-credential:codex-app-server-candidate"