"""Client for vmd's unix socket. Newline-delimited JSON, one request per line, one reply per line. There is no framing beyond that and no session state in the protocol - vmd holds the session, because there is only ever one. Used by two callers: `vmctl` on the host, for driving a session by hand with no model in the loop, and the agent container, where each verb becomes a tool the sub-agent can call. Keeping them on the same client is deliberate: whatever the agent can do, a person can do by hand in the same order, which is what makes a misbehaving session reproducible. """ from __future__ import annotations import json import socket from typing import Any SOCKET_PATH = "/run/owuivm/agent/agent.sock" class VmdError(RuntimeError): """vmd refused a verb. The message is written for a model to read.""" class VmdUnavailable(RuntimeError): """vmd is not running, or the socket is not reachable from here.""" class Vmd: def __init__(self, path: str = SOCKET_PATH, timeout: float = 1800.0) -> None: self.path = path self.timeout = timeout self._sock: socket.socket | None = None self._buf = b"" def __enter__(self) -> "Vmd": self.connect() return self def __exit__(self, *_exc: object) -> None: self.close() def connect(self) -> None: try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(self.timeout) sock.connect(self.path) except OSError as exc: raise VmdUnavailable( f"cannot reach the VM daemon at {self.path}: {exc}. " "It is not running, or this process is not in the group that " "may talk to it." ) from exc self._sock = sock def close(self) -> None: if self._sock is not None: try: self._sock.close() finally: self._sock = None def call(self, verb: str, **args: Any) -> dict[str, Any]: if self._sock is None: self.connect() assert self._sock is not None self._sock.sendall(json.dumps({"verb": verb, "args": args}).encode() + b"\n") while True: line, sep, rest = self._buf.partition(b"\n") if sep: self._buf = rest reply = json.loads(line) if not reply.get("ok"): raise VmdError(reply.get("error", "the VM daemon refused, with no reason given")) return reply.get("result") or {} chunk = self._sock.recv(1 << 20) if not chunk: raise VmdUnavailable("the VM daemon closed the connection") self._buf += chunk