cloudagent-daemon 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ """Unix-socket IPC between daemon-main and mcp_bridge subprocesses.
2
+
3
+ The bridge process (one per claude subprocess) cannot talk to the backend
4
+ directly — only daemon-main owns the WebSocket. Bridges therefore tunnel
5
+ their tool calls over a unix socket to the daemon main process, which
6
+ dispatches the request (typically into ``OutboundQueue.send_tool_call``)
7
+ and returns a result.
8
+
9
+ Wire format: line-delimited JSON, one message per ``\\n``.
10
+
11
+ Request:
12
+ {"request_id": "ipc_...", "op": "tool_call", "args": {...}}
13
+
14
+ Response:
15
+ {"request_id": "ipc_...", "result": {...}}
16
+ {"request_id": "ipc_...", "error": {"code": "...", "message": "..."}}
17
+
18
+ The IPC layer is op-agnostic — the bridge picks the op (e.g. ``tool_call``)
19
+ and the daemon-main installs a handler that routes by ``op``.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ import os
27
+ import uuid
28
+ from collections.abc import Awaitable, Callable
29
+ from pathlib import Path
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ def default_socket_path(profile: str | None = None) -> Path:
34
+ """Profile-aware accessor: ``bridge.sock`` under the per-profile root.
35
+
36
+ Default profile keeps the legacy ``~/.cloudagent/daemon/bridge.sock``
37
+ so existing installs and downstream tools (desktop daemon-mcp, FE
38
+ setup) keep working byte-for-byte. Pass an explicit name (e.g.
39
+ ``"fr"``) to land at ``~/.cloudagent/daemon/profiles/fr/bridge.sock``.
40
+ """
41
+ # Lazy import: ``paths`` lives in the same package and importing at
42
+ # module top would couple bottom-of-graph IPC code to a sibling we
43
+ # would otherwise be free to refactor.
44
+ from .paths import profile_socket_path
45
+
46
+ return profile_socket_path(profile)
47
+
48
+
49
+ # Backward-compatible module-level constant — pre-multi-profile callers
50
+ # (and tests) read this directly. Always points at the default-profile
51
+ # socket; named profiles must use ``default_socket_path("...")``.
52
+ DEFAULT_SOCKET_PATH = default_socket_path()
53
+
54
+ # Generous bound on a single line so a runaway producer can't OOM us.
55
+ _MAX_LINE_BYTES = 4 * 1024 * 1024 # 4 MiB
56
+
57
+
58
+ Handler = Callable[[dict], Awaitable[dict]]
59
+
60
+
61
+ class UnixSocketServer:
62
+ """Daemon-main side: accepts multiple bridge connections concurrently.
63
+
64
+ Each accepted connection is read line-by-line. For every line, the
65
+ handler is invoked with the parsed dict; whatever the handler returns
66
+ (a dict containing ``result`` or ``error``) is echoed back with the
67
+ same ``request_id``.
68
+ """
69
+
70
+ def __init__(self, socket_path: Path, handler: Handler) -> None:
71
+ self._socket_path = Path(socket_path)
72
+ self._handler = handler
73
+ self._server: asyncio.base_events.Server | None = None
74
+ self._tasks: set[asyncio.Task] = set()
75
+
76
+ @property
77
+ def socket_path(self) -> Path:
78
+ return self._socket_path
79
+
80
+ async def start(self) -> None:
81
+ # Remove a stale socket file if present — start_unix_server will
82
+ # fail with EADDRINUSE otherwise.
83
+ if self._socket_path.exists():
84
+ self._socket_path.unlink()
85
+ self._socket_path.parent.mkdir(parents=True, exist_ok=True)
86
+ self._server = await asyncio.start_unix_server(
87
+ self._handle_connection, path=str(self._socket_path)
88
+ )
89
+ # Lock down permissions: only the owning user may connect.
90
+ os.chmod(self._socket_path, 0o600)
91
+
92
+ async def stop(self) -> None:
93
+ if self._server is not None:
94
+ self._server.close()
95
+ await self._server.wait_closed()
96
+ self._server = None
97
+ # Cancel any in-flight connection handlers.
98
+ for task in list(self._tasks):
99
+ if not task.done():
100
+ task.cancel()
101
+ if self._tasks:
102
+ await asyncio.gather(*self._tasks, return_exceptions=True)
103
+ self._tasks.clear()
104
+ # Best-effort cleanup of the socket file.
105
+ try:
106
+ if self._socket_path.exists():
107
+ self._socket_path.unlink()
108
+ except OSError:
109
+ pass
110
+
111
+ async def _handle_connection(
112
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
113
+ ) -> None:
114
+ # 1k #55 — give each accepted connection a short id so we can
115
+ # correlate request/response lines across concurrent clients.
116
+ conn_id = uuid.uuid4().hex[:8]
117
+ task = asyncio.current_task()
118
+ if task is not None:
119
+ self._tasks.add(task)
120
+ logger.info("unix_socket: connection ACCEPTED conn=%s", conn_id)
121
+ try:
122
+ while True:
123
+ try:
124
+ line = await reader.readuntil(b"\n")
125
+ except asyncio.IncompleteReadError:
126
+ logger.info(
127
+ "unix_socket: connection EOF conn=%s — client closed",
128
+ conn_id,
129
+ )
130
+ return
131
+ except asyncio.LimitOverrunError:
132
+ # Drain & abort: can't recover frame alignment.
133
+ logger.warning(
134
+ "unix_socket: limit overrun conn=%s — aborting",
135
+ conn_id,
136
+ )
137
+ return
138
+ except (ConnectionResetError, BrokenPipeError, OSError) as exc:
139
+ # 1k #55 — surface unexpected socket errors that
140
+ # previously propagated silently out of the handler
141
+ # task and were lost. Log & return so the connection
142
+ # is cleanly closed (and the client gets a fresh
143
+ # opportunity on the next ``UnixSocketClient``).
144
+ logger.warning(
145
+ "unix_socket: read error conn=%s err=%r — closing",
146
+ conn_id, exc,
147
+ )
148
+ return
149
+ if not line:
150
+ return
151
+ if len(line) > _MAX_LINE_BYTES:
152
+ logger.warning(
153
+ "unix_socket: oversized line conn=%s bytes=%d — closing",
154
+ conn_id, len(line),
155
+ )
156
+ return
157
+ try:
158
+ msg = json.loads(line.decode("utf-8"))
159
+ except (UnicodeDecodeError, json.JSONDecodeError):
160
+ # Malformed line — skip; protocol violation but no
161
+ # request_id to respond against.
162
+ logger.warning(
163
+ "unix_socket: malformed line conn=%s — skipping",
164
+ conn_id,
165
+ )
166
+ continue
167
+ request_id = msg.get("request_id")
168
+ logger.info(
169
+ "unix_socket: REQUEST conn=%s rid=%s op=%s",
170
+ conn_id, request_id, msg.get("op"),
171
+ )
172
+ try:
173
+ response = await self._handler(msg)
174
+ except Exception as exc: # noqa: BLE001
175
+ logger.exception(
176
+ "unix_socket: handler raised conn=%s rid=%s",
177
+ conn_id, request_id,
178
+ )
179
+ response = {
180
+ "error": {
181
+ "code": "HANDLER_EXCEPTION",
182
+ "message": f"{type(exc).__name__}: {exc}",
183
+ }
184
+ }
185
+ payload: dict = {"request_id": request_id, **response}
186
+ try:
187
+ writer.write(
188
+ (json.dumps(payload, separators=(",", ":")) + "\n").encode(
189
+ "utf-8"
190
+ )
191
+ )
192
+ await writer.drain()
193
+ except (ConnectionResetError, BrokenPipeError) as exc:
194
+ logger.warning(
195
+ "unix_socket: write error conn=%s rid=%s err=%r — closing",
196
+ conn_id, request_id, exc,
197
+ )
198
+ return
199
+ logger.info(
200
+ "unix_socket: RESPONSE conn=%s rid=%s sent=%d bytes",
201
+ conn_id, request_id, len(payload),
202
+ )
203
+ finally:
204
+ try:
205
+ writer.close()
206
+ await writer.wait_closed()
207
+ except Exception: # noqa: BLE001
208
+ pass
209
+ if task is not None:
210
+ self._tasks.discard(task)
211
+ logger.info("unix_socket: connection CLOSED conn=%s", conn_id)
212
+
213
+
214
+ class UnixSocketClient:
215
+ """Bridge side: connects, sends one request, awaits the matching response.
216
+
217
+ Used as an async context manager so the connection always closes
218
+ cleanly. Multiple sequential ``call`` invocations on the same client
219
+ are allowed (and used by long-running bridges that issue many tool
220
+ calls), but the wire is strictly request/response — no pipelining.
221
+ """
222
+
223
+ def __init__(self, socket_path: Path) -> None:
224
+ self._socket_path = Path(socket_path)
225
+ self._reader: asyncio.StreamReader | None = None
226
+ self._writer: asyncio.StreamWriter | None = None
227
+
228
+ async def __aenter__(self) -> "UnixSocketClient":
229
+ self._reader, self._writer = await asyncio.open_unix_connection(
230
+ path=str(self._socket_path)
231
+ )
232
+ return self
233
+
234
+ async def __aexit__(self, *args: object) -> None:
235
+ if self._writer is not None:
236
+ try:
237
+ self._writer.close()
238
+ await self._writer.wait_closed()
239
+ except Exception: # noqa: BLE001
240
+ pass
241
+ self._reader = None
242
+ self._writer = None
243
+
244
+ async def call(
245
+ self, op: str, args: dict, timeout: float = 30.0
246
+ ) -> dict:
247
+ if self._reader is None or self._writer is None:
248
+ raise RuntimeError("UnixSocketClient is not connected")
249
+ request_id = f"ipc_{uuid.uuid4().hex[:12]}"
250
+ payload = {"request_id": request_id, "op": op, "args": args}
251
+ line = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
252
+ self._writer.write(line)
253
+ await self._writer.drain()
254
+
255
+ try:
256
+ response_line = await asyncio.wait_for(
257
+ self._reader.readuntil(b"\n"), timeout=timeout
258
+ )
259
+ except asyncio.IncompleteReadError as exc:
260
+ raise RuntimeError("IPC connection closed before response") from exc
261
+ except TimeoutError as exc:
262
+ raise RuntimeError(f"IPC call timed out after {timeout}s") from exc
263
+ try:
264
+ response = json.loads(response_line.decode("utf-8"))
265
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
266
+ raise RuntimeError(f"Malformed IPC response: {response_line!r}") from exc
267
+
268
+ if response.get("request_id") != request_id:
269
+ raise RuntimeError(
270
+ f"IPC response request_id mismatch: "
271
+ f"sent {request_id}, got {response.get('request_id')}"
272
+ )
273
+ if "error" in response:
274
+ err = response["error"] or {}
275
+ code = err.get("code", "UNKNOWN")
276
+ message = err.get("message", "")
277
+ raise RuntimeError(f"IPC error {code}: {message}".rstrip(": "))
278
+ return response.get("result", {})