"""Local RPC transport for ``opc-collab`` calls. The external agent still invokes the normal ``opc-collab`` CLI. When OpenOPC spawns that agent, the broker exposes a short-lived local endpoint and injects its address/token into the environment. The CLI then sends the collaboration tool call to the already-running broker, so database writes stay in the host runtime instead of inside the agent sandbox. """ from __future__ import annotations import asyncio import contextlib import errno import json import os import select import secrets import shutil import socket import tempfile import uuid from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, Literal OPC_COLLAB_RPC_PATH = "OPC_COLLAB_RPC_PATH" OPC_COLLAB_RPC_TOKEN = "OPC_COLLAB_RPC_TOKEN" OPC_COLLAB_RPC_TRANSPORT = "OPC_COLLAB_RPC_TRANSPORT" OPC_COLLAB_RPC_HOST = "OPC_COLLAB_RPC_HOST" OPC_COLLAB_RPC_PORT = "OPC_COLLAB_RPC_PORT" _RPC_TIMEOUT_SECONDS = 30.0 _RPC_MAX_BYTES = 16 * 1024 * 1024 DispatchCallable = Callable[[str, dict[str, Any]], Awaitable[tuple[dict[str, Any], bool]]] RpcTransport = Literal["auto", "fifo", "tcp"] def _infrastructure_error(message: str, *, tool_name: str = "") -> dict[str, Any]: payload = { "error": str(message or "collaboration broker RPC failed"), "error_type": "infrastructure", "retryable": True, } if tool_name: payload["tool_name"] = tool_name return payload def fifo_rpc_supported() -> bool: """Return whether this runtime can create POSIX FIFOs.""" return os.name != "nt" and callable(getattr(os, "mkfifo", None)) def default_collaboration_rpc_transport() -> Literal["fifo", "tcp"]: return "fifo" if fifo_rpc_supported() else "tcp" def resolve_collaboration_rpc_transport( transport: str | None = "auto", ) -> Literal["fifo", "tcp"]: normalized = str(transport or "auto").strip().lower() if normalized in {"", "auto"}: return default_collaboration_rpc_transport() if normalized == "fifo": if not fifo_rpc_supported(): raise RuntimeError("FIFO collaboration RPC is unavailable on this platform") return "fifo" if normalized == "tcp": return "tcp" raise ValueError(f"Unsupported collaboration RPC transport: {transport}") def rpc_env_available(env: Mapping[str, str] | None = None) -> bool: source = env if env is not None else os.environ token = str(source.get(OPC_COLLAB_RPC_TOKEN, "")).strip() if not token: return False transport = str(source.get(OPC_COLLAB_RPC_TRANSPORT, "")).strip().lower() if not transport: # Legacy FIFO environment from older brokers. return bool(str(source.get(OPC_COLLAB_RPC_PATH, "")).strip()) if transport == "fifo": return bool(str(source.get(OPC_COLLAB_RPC_PATH, "")).strip()) if transport == "tcp": host = str(source.get(OPC_COLLAB_RPC_HOST, "")).strip() raw_port = str(source.get(OPC_COLLAB_RPC_PORT, "")).strip() try: port = int(raw_port) except ValueError: return False return bool(host) and 0 < port <= 65535 return False def rpc_env_configured(env: Mapping[str, str] | None = None) -> bool: source = env if env is not None else os.environ return any( str(source.get(key, "")).strip() for key in ( OPC_COLLAB_RPC_TRANSPORT, OPC_COLLAB_RPC_PATH, OPC_COLLAB_RPC_HOST, OPC_COLLAB_RPC_PORT, OPC_COLLAB_RPC_TOKEN, ) ) def _json_line(payload: dict[str, Any]) -> bytes: return json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + b"\n" def _decode_rpc_response(raw: bytes, *, tool_name: str) -> tuple[dict[str, Any], bool]: try: response = json.loads(raw.decode("utf-8")) except json.JSONDecodeError as exc: return _infrastructure_error(f"collaboration broker RPC returned invalid JSON: {exc}", tool_name=tool_name), True if not isinstance(response, dict): return _infrastructure_error("collaboration broker RPC returned a non-object response", tool_name=tool_name), True result = response.get("result") normalized = result if isinstance(result, dict) else {"result": result} is_error = bool(response.get("is_error")) or "error" in normalized return normalized, is_error def _request_payload( tool_name: str, args: dict[str, Any], *, token: str, response_path: str = "", ) -> dict[str, Any]: request = { "token": token, "tool_name": str(tool_name or "").strip(), "args": dict(args or {}), } if response_path: request["response_path"] = response_path return request def _write_fifo_nonblocking(path: Path, payload: dict[str, Any]) -> None: data = _json_line(payload) if len(data) > _RPC_MAX_BYTES: raise ValueError("collaboration RPC request exceeds max payload size") try: fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK) except OSError as exc: if exc.errno == errno.ENXIO: raise RuntimeError("collaboration broker RPC is not accepting requests") from exc raise try: view = memoryview(data) while view: try: written = os.write(fd, view) view = view[written:] except BlockingIOError: _readable, writable, _errors = select.select([], [fd], [], _RPC_TIMEOUT_SECONDS) if not writable: raise TimeoutError("collaboration broker RPC write timed out") finally: os.close(fd) async def _read_fifo_response(fd: int, *, timeout_seconds: float) -> bytes: loop = asyncio.get_running_loop() deadline = loop.time() + timeout_seconds chunks = bytearray() while loop.time() < deadline: try: chunk = os.read(fd, 65536) except BlockingIOError: await asyncio.sleep(0.02) continue if chunk: chunks.extend(chunk) if b"\n" in chunk: line, _sep, _rest = bytes(chunks).partition(b"\n") return line + b"\n" if len(chunks) > _RPC_MAX_BYTES: raise RuntimeError("collaboration broker RPC response exceeds max payload size") else: await asyncio.sleep(0.02) raise TimeoutError("collaboration broker RPC response timed out") async def call_collaboration_rpc( tool_name: str, args: dict[str, Any], *, env: Mapping[str, str] | None = None, ) -> tuple[dict[str, Any], bool]: """Call the broker-owned collaboration RPC endpoint from ``opc-collab``.""" source = env if env is not None else os.environ transport = str(source.get(OPC_COLLAB_RPC_TRANSPORT, "")).strip().lower() or "fifo" if transport == "fifo": return await _call_fifo_collaboration_rpc(tool_name, args, env=source) if transport == "tcp": return await _call_tcp_collaboration_rpc(tool_name, args, env=source) return _infrastructure_error( f"collaboration broker RPC transport is unsupported: {transport}", tool_name=tool_name, ), True async def _call_fifo_collaboration_rpc( tool_name: str, args: dict[str, Any], *, env: Mapping[str, str], ) -> tuple[dict[str, Any], bool]: token = str(env.get(OPC_COLLAB_RPC_TOKEN, "")).strip() raw_request_path = str(env.get(OPC_COLLAB_RPC_PATH, "")).strip() if not raw_request_path or not token: return _infrastructure_error("collaboration broker RPC is not configured", tool_name=tool_name), True if not fifo_rpc_supported(): return _infrastructure_error( "FIFO collaboration broker RPC is unavailable on this platform", tool_name=tool_name, ), True request_path = Path(raw_request_path) response_dir = request_path.parent / "responses" response_path = response_dir / f"{uuid.uuid4().hex}.fifo" response_fd: int | None = None try: response_dir.mkdir(parents=True, exist_ok=True) os.mkfifo(response_path, 0o600) response_fd = os.open(response_path, os.O_RDONLY | os.O_NONBLOCK) request = _request_payload( tool_name, args, token=token, response_path=str(response_path), ) _write_fifo_nonblocking(request_path, request) raw = await _read_fifo_response(response_fd, timeout_seconds=_RPC_TIMEOUT_SECONDS) except Exception as exc: return _infrastructure_error(f"collaboration broker RPC failed: {exc}", tool_name=tool_name), True finally: if response_fd is not None: with contextlib.suppress(OSError): os.close(response_fd) with contextlib.suppress(FileNotFoundError): response_path.unlink() return _decode_rpc_response(raw, tool_name=tool_name) async def _call_tcp_collaboration_rpc( tool_name: str, args: dict[str, Any], *, env: Mapping[str, str], ) -> tuple[dict[str, Any], bool]: host = str(env.get(OPC_COLLAB_RPC_HOST, "")).strip() raw_port = str(env.get(OPC_COLLAB_RPC_PORT, "")).strip() token = str(env.get(OPC_COLLAB_RPC_TOKEN, "")).strip() if not host or not raw_port or not token: return _infrastructure_error("collaboration broker RPC is not configured", tool_name=tool_name), True try: port = int(raw_port) except ValueError: return _infrastructure_error( f"collaboration broker RPC port is invalid: {raw_port}", tool_name=tool_name, ), True if not 0 < port <= 65535: return _infrastructure_error( f"collaboration broker RPC port is out of range: {port}", tool_name=tool_name, ), True request = _request_payload(tool_name, args, token=token) data = _json_line(request) if len(data) > _RPC_MAX_BYTES: return _infrastructure_error( "collaboration RPC request exceeds max payload size", tool_name=tool_name, ), True writer: asyncio.StreamWriter | None = None try: reader, writer = await asyncio.wait_for( asyncio.open_connection(host=host, port=port, limit=_RPC_MAX_BYTES + 1024), timeout=_RPC_TIMEOUT_SECONDS, ) writer.write(data) await asyncio.wait_for(writer.drain(), timeout=_RPC_TIMEOUT_SECONDS) raw = await _read_stream_line(reader, timeout_seconds=_RPC_TIMEOUT_SECONDS) except Exception as exc: return _infrastructure_error(f"collaboration broker RPC failed: {exc}", tool_name=tool_name), True finally: if writer is not None: writer.close() with contextlib.suppress(Exception): await writer.wait_closed() return _decode_rpc_response(raw, tool_name=tool_name) @dataclass class CollaborationRpcServer: transport: Literal["fifo", "tcp"] token: str request_path: Path | None = None rpc_dir: Path | None = None request_fd: int | None = None task: asyncio.Task[None] | None = None tcp_server: asyncio.AbstractServer | None = None host: str = "" port: int = 0 @property def client_env(self) -> dict[str, str]: if self.transport == "tcp": return { OPC_COLLAB_RPC_TRANSPORT: "tcp", OPC_COLLAB_RPC_HOST: self.host, OPC_COLLAB_RPC_PORT: str(self.port), OPC_COLLAB_RPC_TOKEN: self.token, } return { OPC_COLLAB_RPC_PATH: str(self.request_path or ""), OPC_COLLAB_RPC_TOKEN: self.token, OPC_COLLAB_RPC_TRANSPORT: "fifo", } async def close(self) -> None: if self.tcp_server is not None: self.tcp_server.close() with contextlib.suppress(Exception): await self.tcp_server.wait_closed() if self.task is not None: self.task.cancel() with contextlib.suppress(asyncio.CancelledError): await self.task if self.request_fd is not None: with contextlib.suppress(OSError): os.close(self.request_fd) if self.rpc_dir is not None: shutil.rmtree(self.rpc_dir, ignore_errors=True) async def start_collaboration_rpc_server( dispatch: DispatchCallable, *, transport_parent: str | os.PathLike[str] | None = None, transport: RpcTransport = "auto", ) -> CollaborationRpcServer | None: """Start a broker-local collaboration RPC server.""" resolved_transport = resolve_collaboration_rpc_transport(transport) if resolved_transport == "tcp": return await _start_tcp_collaboration_rpc_server(dispatch) return await _start_fifo_collaboration_rpc_server(dispatch, transport_parent=transport_parent) async def _start_fifo_collaboration_rpc_server( dispatch: DispatchCallable, *, transport_parent: str | os.PathLike[str] | None = None, ) -> CollaborationRpcServer: parent = Path(transport_parent) if transport_parent else Path(tempfile.gettempdir()) rpc_dir = Path(tempfile.mkdtemp(prefix="openopc-collab-rpc-", dir=str(parent))) request_path = rpc_dir / "requests.fifo" token = secrets.token_urlsafe(32) os.mkfifo(request_path, 0o600) request_fd = os.open(request_path, os.O_RDONLY | os.O_NONBLOCK) async def _serve() -> None: buffer = bytearray() while True: try: chunk = os.read(request_fd, 65536) except BlockingIOError: await asyncio.sleep(0.02) continue if not chunk: await asyncio.sleep(0.02) continue buffer.extend(chunk) if len(buffer) > _RPC_MAX_BYTES: buffer.clear() continue while True: newline_index = buffer.find(b"\n") if newline_index < 0: break raw = bytes(buffer[:newline_index]) del buffer[: newline_index + 1] await _handle_request_line(raw) async def _handle_request_line(raw: bytes) -> None: if not raw: return try: request = json.loads(raw.decode("utf-8")) except json.JSONDecodeError: return if not isinstance(request, dict): return response = await _handle_rpc_request(request, dispatch=dispatch, token=token) await _respond(request, response) task = asyncio.create_task(_serve()) return CollaborationRpcServer( transport="fifo", request_path=request_path, rpc_dir=rpc_dir, token=token, request_fd=request_fd, task=task, ) async def _start_tcp_collaboration_rpc_server(dispatch: DispatchCallable) -> CollaborationRpcServer: token = secrets.token_urlsafe(32) async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: try: raw = await _read_stream_line(reader, timeout_seconds=_RPC_TIMEOUT_SECONDS) try: request = json.loads(raw.decode("utf-8")) except json.JSONDecodeError as exc: response = { "result": _infrastructure_error( f"collaboration broker RPC received invalid JSON: {exc}", ), "is_error": True, } else: if isinstance(request, dict): response = await _handle_rpc_request(request, dispatch=dispatch, token=token) else: response = { "result": _infrastructure_error("collaboration broker RPC received a non-object request"), "is_error": True, } data = _json_line(response) if len(data) > _RPC_MAX_BYTES: data = _json_line( { "result": _infrastructure_error("collaboration broker RPC response exceeds max payload size"), "is_error": True, } ) writer.write(data) await writer.drain() except Exception: with contextlib.suppress(Exception): writer.write( _json_line( { "result": _infrastructure_error("collaboration broker RPC connection failed"), "is_error": True, } ) ) await writer.drain() finally: writer.close() with contextlib.suppress(Exception): await writer.wait_closed() server = await asyncio.start_server( _handle_client, host="127.0.0.1", port=0, family=socket.AF_INET, limit=_RPC_MAX_BYTES + 1024, ) sockets = server.sockets or [] if not sockets: server.close() await server.wait_closed() raise RuntimeError("collaboration RPC TCP server did not expose a listening socket") host, port = sockets[0].getsockname()[:2] return CollaborationRpcServer( transport="tcp", token=token, tcp_server=server, host=str(host), port=int(port), ) async def _read_stream_line( reader: asyncio.StreamReader, *, timeout_seconds: float, ) -> bytes: buffer = bytearray() while True: chunk = await asyncio.wait_for(reader.read(65536), timeout=timeout_seconds) if not chunk: if buffer: return bytes(buffer) raise RuntimeError("collaboration broker RPC connection closed before a response") buffer.extend(chunk) if len(buffer) > _RPC_MAX_BYTES: raise RuntimeError("collaboration broker RPC payload exceeds max size") newline_index = buffer.find(b"\n") if newline_index >= 0: return bytes(buffer[: newline_index + 1]) async def _handle_rpc_request( request: dict[str, Any], *, dispatch: DispatchCallable, token: str, ) -> dict[str, Any]: tool_name = str(request.get("tool_name", "") or "").strip() if str(request.get("token", "")) != token: return { "result": _infrastructure_error("collaboration RPC token rejected", tool_name=tool_name), "is_error": True, } raw_args = request.get("args") tool_args = raw_args if isinstance(raw_args, dict) else {} try: result, is_error = await dispatch(tool_name, tool_args) return {"result": result, "is_error": bool(is_error)} except Exception as exc: return { "result": _infrastructure_error( f"collaboration broker RPC failed: {exc}", tool_name=tool_name, ), "is_error": True, } async def _respond(request: dict[str, Any], response: dict[str, Any]) -> None: response_path = Path(str(request.get("response_path", "") or "").strip()) if not response_path: return with contextlib.suppress(Exception): _write_fifo_nonblocking(response_path, response)