546 lines
19 KiB
Python
546 lines
19 KiB
Python
"""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)
|