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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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", {})
|