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,441 @@
1
+ """Stdio MCP server invoked by the claude subprocess.
2
+
3
+ Architecture (see spec §6.2 / §4.7):
4
+
5
+ claude (subprocess of ClaudeRunner)
6
+ │ MCP stdio (NDJSON, mcp protocol)
7
+ ▼
8
+ mcp_bridge subprocess (`cloudagent-daemon mcp-bridge ...`)
9
+ │ unix socket (line-delimited JSON request/response)
10
+ ▼
11
+ daemon-main process
12
+
13
+ Each tool the bridge registers, when invoked by claude, opens a unix-socket
14
+ connection back to daemon-main and tunnels the call as an IPC ``tool_call``
15
+ op. The daemon-main process is the only place that owns the WebSocket.
16
+
17
+ Instance-level isolation (spec §6.4): ``build_server`` reads
18
+ ``identity.json`` and only registers the 4 chief-only tools when
19
+ ``is_chief is True``. Staff agents never see those tools, so the LLM
20
+ cannot attempt calls it is not allowed to make — no reliance on LLM
21
+ self-restraint. Server-side enforcement (chief-hallucination-guard in
22
+ ``daemon_dispatcher.py``) still provides a second line of defence.
23
+
24
+ Session ID (1k hotfix): the bridge reads the cloudagent business
25
+ ``session_id`` for the current wake from
26
+ ``~/.cloudagent/agents/<agent_id>/.meta/current-session.txt``. The
27
+ daemon (claude_runner) writes that file BEFORE spawning ``claude`` and
28
+ removes it AFTER the wake completes. When the file is missing (legacy
29
+ clients, cleanup race, or stale spawns) the bridge falls back to ``None``
30
+ and the backend dispatcher will reject the call with INVALID_ARGS — far
31
+ preferable to a silent cross-session mix-up.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import sys
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from mcp.server.fastmcp import FastMCP
42
+
43
+ from .unix_socket import DEFAULT_SOCKET_PATH, UnixSocketClient
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Tool set split per spec §6.4 instance-level isolation.
48
+ COMMON_TOOLS: frozenset[str] = frozenset(
49
+ {"send_message", "list_messages", "upload_file", "schedule_reminder"}
50
+ )
51
+ CHIEF_ONLY_TOOLS: frozenset[str] = frozenset(
52
+ {"delegate_to_staff", "close_task", "request_user_approval", "set_company_status"}
53
+ )
54
+
55
+
56
+ def _read_identity(agent_id: str) -> dict:
57
+ """Defensive read of ``~/.cloudagent/agents/<id>/.meta/identity.json``.
58
+
59
+ Returns the parsed dict on success. On any failure (missing file,
60
+ invalid JSON, OS error) returns ``{}`` which causes ``build_server``
61
+ to treat the agent as a staff member (``is_chief`` defaults to
62
+ ``False`` — the safe/secure default).
63
+ """
64
+ path = Path.home() / ".cloudagent" / "agents" / agent_id / ".meta" / "identity.json"
65
+ try:
66
+ return json.loads(path.read_text())
67
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
68
+ return {}
69
+
70
+
71
+ async def _query_daemon_session(
72
+ agent_id: str, socket_path: Path
73
+ ) -> str | None:
74
+ """(c) IPC architecture upgrade — query daemon in-memory session map.
75
+
76
+ Sends a ``get_current_session`` request over the unix socket and
77
+ returns the session_id from the daemon's memory (set by ClaudeRunner
78
+ before spawning this very bridge). Faster and more reliable than the
79
+ file-based path because it bypasses filesystem caching / mode issues.
80
+
81
+ Returns ``None`` on any failure (socket missing, timeout, old daemon
82
+ without the op) so the caller can fall back to the file path.
83
+ """
84
+ try:
85
+ async with UnixSocketClient(socket_path) as ipc:
86
+ result = await ipc.call(
87
+ "get_current_session",
88
+ {"agent_id": agent_id},
89
+ timeout=5.0,
90
+ )
91
+ sid = result.get("session_id")
92
+ return sid if isinstance(sid, str) and sid else None
93
+ except Exception: # noqa: BLE001
94
+ # Socket missing, old daemon (UNKNOWN_OP), or timeout — fall
95
+ # through to file-based path. Not an error: backward compat.
96
+ return None
97
+
98
+
99
+ def _read_current_session(
100
+ agent_id: str, workspace_root: Path | None = None
101
+ ) -> str | None:
102
+ """Defensive read of ``.meta/current-session.txt`` (1k hotfix).
103
+
104
+ Returns the session_id string if present and non-empty, ``None``
105
+ otherwise. Tolerates a missing file — that situation is normal for
106
+ legacy daemons that haven't been redeployed yet, and we'd rather
107
+ let the backend reject with INVALID_ARGS than crash the bridge.
108
+
109
+ Profile-aware (2026-05-18 fix): named profiles store agents under
110
+ ``~/.cloudagent/agents-<profile>/`` (see paths.profile_workspace_root).
111
+ Without this resolution, the bridge read the default-profile path and
112
+ always missed current-session.txt for daily/staging/etc → every MCP
113
+ tool call dispatched ``session_id=None`` → backend rejected as
114
+ "missing required arg" → claude retried 30+s per turn.
115
+
116
+ (c) fallback: called after ``_query_daemon_session`` fails (old
117
+ daemon without the ``get_current_session`` IPC op or socket missing).
118
+ """
119
+ if workspace_root is None:
120
+ # Read CLOUDAGENT_DAEMON_PROFILE env var written by service.py /
121
+ # main_loop.py for spawned children (claude inherits it).
122
+ from .paths import profile_workspace_root
123
+ import os
124
+ profile_env = os.environ.get("CLOUDAGENT_DAEMON_PROFILE")
125
+ workspace_root = profile_workspace_root(profile_env)
126
+ p = workspace_root / agent_id / ".meta" / "current-session.txt"
127
+ try:
128
+ sid = p.read_text().strip()
129
+ return sid or None
130
+ except (FileNotFoundError, OSError):
131
+ return None
132
+
133
+
134
+ async def _ipc_tool_call(
135
+ socket_path: Path,
136
+ agent_id: str,
137
+ tool_name: str,
138
+ tool_args: dict,
139
+ session_id: str | None,
140
+ ) -> dict:
141
+ """Single round-trip: open socket → send tool_call → return result."""
142
+ # 1k #55 — log the tool dispatch BEFORE we touch the socket so we can
143
+ # tell apart "claude never invoked the tool" vs "tool invoked but
144
+ # bridge couldn't reach the daemon".
145
+ logger.info(
146
+ "mcp_bridge: tool dispatch ENTER tool=%s agent=%s session=%s",
147
+ tool_name, agent_id, session_id,
148
+ )
149
+ try:
150
+ async with UnixSocketClient(socket_path) as ipc:
151
+ logger.info(
152
+ "mcp_bridge: ipc connected socket=%s tool=%s agent=%s session=%s",
153
+ socket_path, tool_name, agent_id, session_id,
154
+ )
155
+ result = await ipc.call(
156
+ "tool_call",
157
+ {
158
+ "agent_id": agent_id,
159
+ "session_id": session_id,
160
+ "tool_name": tool_name,
161
+ "tool_args": tool_args,
162
+ },
163
+ )
164
+ logger.info(
165
+ "mcp_bridge: tool dispatch EXIT tool=%s ok=%s",
166
+ tool_name, "ok" in str(result) or result,
167
+ )
168
+ return result
169
+ except Exception:
170
+ logger.exception(
171
+ "mcp_bridge: tool dispatch FAILED tool=%s agent=%s",
172
+ tool_name, agent_id,
173
+ )
174
+ raise
175
+
176
+
177
+ def build_server(
178
+ agent_id: str,
179
+ socket_path: Path = DEFAULT_SOCKET_PATH,
180
+ identity: dict | None = None,
181
+ session_id: str | None = None,
182
+ ) -> FastMCP:
183
+ """Construct a FastMCP server with tools filtered by ``is_chief``.
184
+
185
+ Split out from ``run_bridge`` so unit tests can introspect the tool
186
+ list without spinning up stdio.
187
+
188
+ Chief agents get all 8 tools (4 common + 4 chief-only).
189
+ Staff agents (``is_chief`` absent or ``False``) get only the 4 common
190
+ tools — the LLM never sees the chief-only names, implementing the
191
+ instance-level isolation required by spec §6.4.
192
+
193
+ ``session_id`` (1k hotfix): the cloudagent business session_id that
194
+ every IPC ``tool_call`` payload must carry. Reads the current
195
+ deliver's session_id; the daemon's tool dispatcher requires it to
196
+ route the tool result back to the right business conversation.
197
+ Pass ``None`` only when no session is known (the backend will then
198
+ reject with INVALID_ARGS — that's the expected behaviour and
199
+ surfaces the bug rather than masking it).
200
+ """
201
+ if identity is None:
202
+ identity = _read_identity(agent_id)
203
+
204
+ is_chief: bool = identity.get("is_chief") is True # False when missing or non-True
205
+
206
+ server = FastMCP(name=f"cloudagent-bridge-{agent_id}")
207
+
208
+ # ---- Common tools (all agents) ----------------------------------------
209
+
210
+ @server.tool(
211
+ description=(
212
+ "Send a message to another agent or the user. When replying "
213
+ "(kind=\"reply\" or \"question\"), MUST set parent_msg_id to the "
214
+ "inbound message's message_id (shown in the [meta] header of "
215
+ "your stdin) so the sender's wait_for_replies satisfies; "
216
+ "leave parent_msg_id=None for unsolicited kind=\"task\" / "
217
+ "\"report\" sends."
218
+ )
219
+ )
220
+ async def send_message(
221
+ target: str,
222
+ body: str,
223
+ kind: str = "task",
224
+ parent_msg_id: int | None = None,
225
+ ) -> dict[str, Any]:
226
+ # Hot-fix 2026-05-12 — local_daemon staff replies were dropping
227
+ # ``parent_msg_id``, which left the chief's ``wait_for_replies``
228
+ # forever stuck on missing parents (cloud_pod staff already
229
+ # passed the id by virtue of going through ``send_message`` on
230
+ # the chief's server-side session; the daemon path didn't have
231
+ # the parameter on its tool surface). Now exposed on the tool
232
+ # AND threaded into the IPC payload — backend
233
+ # ``/v1/daemon/tool-call`` keeps the field on the dispatcher
234
+ # call to ``message_service.send`` (林逸舟 wire-in).
235
+ tool_args: dict[str, Any] = {
236
+ "target": target,
237
+ "body": body,
238
+ "kind": kind,
239
+ }
240
+ if parent_msg_id is not None:
241
+ tool_args["parent_msg_id"] = parent_msg_id
242
+ return await _ipc_tool_call(
243
+ socket_path,
244
+ agent_id,
245
+ "send_message",
246
+ tool_args,
247
+ session_id,
248
+ )
249
+
250
+ @server.tool(description="List recent messages in the agent's inbox.")
251
+ async def list_messages(limit: int = 50) -> dict[str, Any]:
252
+ return await _ipc_tool_call(
253
+ socket_path, agent_id, "list_messages", {"limit": limit}, session_id
254
+ )
255
+
256
+ @server.tool(description="Upload a local file and attach it to the conversation.")
257
+ async def upload_file(path: str) -> dict[str, Any]:
258
+ return await _ipc_tool_call(
259
+ socket_path, agent_id, "upload_file", {"path": path}, session_id
260
+ )
261
+
262
+ @server.tool(description="Schedule a reminder for the agent at a future time.")
263
+ async def schedule_reminder(when: str, body: str) -> dict[str, Any]:
264
+ return await _ipc_tool_call(
265
+ socket_path,
266
+ agent_id,
267
+ "schedule_reminder",
268
+ {"when": when, "body": body},
269
+ session_id,
270
+ )
271
+
272
+ # ---- Chief-only tools (registered only when is_chief is True) ---------
273
+
274
+ if is_chief:
275
+
276
+ @server.tool(description="[chief] Delegate work to a staff agent.")
277
+ async def delegate_to_staff(staff_id: str, body: str) -> dict[str, Any]:
278
+ return await _ipc_tool_call(
279
+ socket_path,
280
+ agent_id,
281
+ "delegate_to_staff",
282
+ {"staff_id": staff_id, "body": body},
283
+ session_id,
284
+ )
285
+
286
+ @server.tool(description="[chief] Mark a task closed with a summary.")
287
+ async def close_task(task_id: str, summary: str) -> dict[str, Any]:
288
+ return await _ipc_tool_call(
289
+ socket_path,
290
+ agent_id,
291
+ "close_task",
292
+ {"task_id": task_id, "summary": summary},
293
+ session_id,
294
+ )
295
+
296
+ @server.tool(description="[chief] Ask the user for explicit approval.")
297
+ async def request_user_approval(question: str) -> dict[str, Any]:
298
+ return await _ipc_tool_call(
299
+ socket_path,
300
+ agent_id,
301
+ "request_user_approval",
302
+ {"question": question},
303
+ session_id,
304
+ )
305
+
306
+ @server.tool(description="[chief] Update the company-wide status string.")
307
+ async def set_company_status(status: str) -> dict[str, Any]:
308
+ return await _ipc_tool_call(
309
+ socket_path,
310
+ agent_id,
311
+ "set_company_status",
312
+ {"status": status},
313
+ session_id,
314
+ )
315
+
316
+ return server
317
+
318
+
319
+ def _configure_bridge_logging() -> None:
320
+ """Send bridge logs to stderr — stdout is reserved for MCP stdio framing.
321
+
322
+ ``claude`` invokes the bridge as a stdio MCP subprocess. Anything we
323
+ print to stdout would corrupt the MCP wire protocol (NDJSON-framed).
324
+ We therefore route Python logging to stderr only. ``claude`` captures
325
+ bridge stderr and surfaces it on its own stderr (visible to the
326
+ daemon's spawn supervisor and to manual ``claude --mcp-debug`` runs).
327
+ """
328
+ root = logging.getLogger()
329
+ # Don't double-attach if a parent already wired one.
330
+ if any(
331
+ isinstance(h, logging.StreamHandler) and h.stream is sys.stderr
332
+ for h in root.handlers
333
+ ):
334
+ return
335
+ handler = logging.StreamHandler(stream=sys.stderr)
336
+ handler.setFormatter(
337
+ logging.Formatter(
338
+ "%(asctime)s %(levelname)s mcp_bridge: %(message)s"
339
+ )
340
+ )
341
+ root.addHandler(handler)
342
+ root.setLevel(logging.INFO)
343
+
344
+
345
+ async def run_bridge(
346
+ agent_id: str,
347
+ token_file: Path | None = None,
348
+ socket_path: Path | None = None,
349
+ profile: str | None = None,
350
+ ) -> None:
351
+ """Run the bridge until claude closes stdin.
352
+
353
+ Phase 1 (1i Bug B): the per-spawn token file is **not required**.
354
+ The original spec §6.6 token-file scheme was never wired end-to-end
355
+ (claude_runner only deletes the token post-spawn, never writes it
356
+ pre-spawn), and the bridge does not send the token on IPC calls
357
+ anyway. For Phase 1 we rely on filesystem-level access control —
358
+ the unix socket is mode 0o600 (owner-only). Task 11/1c will revisit
359
+ bridge↔daemon authentication when the token scheme is real.
360
+
361
+ Behaviour:
362
+ * If ``token_file`` is ``None`` or the file is missing → log a
363
+ warning and continue (do NOT abort the bridge — that would crash
364
+ every spawn and the user would see "MCP server cloudagent failed").
365
+ * If the file exists → read it (we don't use the value yet, but
366
+ reading proves we have access; useful for future auth wiring).
367
+ """
368
+ _configure_bridge_logging()
369
+
370
+ # Resolve socket_path lazily so the default reflects the active
371
+ # profile (CLOUDAGENT_DAEMON_PROFILE env var if --profile not
372
+ # passed). Existing callers that pass a Path keep their behaviour.
373
+ if socket_path is None:
374
+ from .unix_socket import default_socket_path
375
+
376
+ socket_path = default_socket_path(profile)
377
+
378
+ logger.info(
379
+ "mcp_bridge: starting agent=%s socket=%s token_file=%s",
380
+ agent_id,
381
+ socket_path,
382
+ token_file,
383
+ )
384
+
385
+ if token_file is not None and token_file.exists():
386
+ try:
387
+ _ = token_file.read_text()
388
+ logger.info("mcp_bridge: token file present (Phase 1: not validated)")
389
+ except OSError as exc:
390
+ logger.warning(
391
+ "mcp_bridge: could not read token file %s: %s "
392
+ "(Phase 1: continuing without token)",
393
+ token_file,
394
+ exc,
395
+ )
396
+ else:
397
+ logger.info(
398
+ "mcp_bridge: no token file (Phase 1: continuing — auth via "
399
+ "unix-socket 0o600 ownership)"
400
+ )
401
+
402
+ identity = _read_identity(agent_id)
403
+ # (c) IPC architecture upgrade — try daemon memory query first
404
+ # (faster, not subject to filesystem caching / mode issues on the
405
+ # freshly-written current-session.txt). Fall back to file on any
406
+ # failure so old daemons without the ``get_current_session`` op
407
+ # keep working.
408
+ current_session_id = await _query_daemon_session(agent_id, socket_path)
409
+ if current_session_id is not None:
410
+ logger.info(
411
+ "mcp_bridge: agent=%s current_session=%s (via socket IPC)",
412
+ agent_id,
413
+ current_session_id,
414
+ )
415
+ else:
416
+ # Fallback: file-based path (old daemon or socket unavailable).
417
+ current_session_id = _read_current_session(agent_id)
418
+ if current_session_id is not None:
419
+ logger.info(
420
+ "mcp_bridge: agent=%s current_session=%s (via file fallback)",
421
+ agent_id,
422
+ current_session_id,
423
+ )
424
+ else:
425
+ logger.warning(
426
+ "mcp_bridge: agent=%s current_session=None "
427
+ "(falling back to file IPC — socket miss + no file)",
428
+ agent_id,
429
+ )
430
+ server = build_server(
431
+ agent_id=agent_id,
432
+ socket_path=socket_path,
433
+ identity=identity,
434
+ session_id=current_session_id,
435
+ )
436
+ logger.info(
437
+ "mcp_bridge: ready agent=%s is_chief=%s — entering stdio loop",
438
+ agent_id,
439
+ identity.get("is_chief") is True,
440
+ )
441
+ await server.run_stdio_async()