alter-runtime 0.3.0__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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,702 @@
1
+ """UnixSocketServer - local JSON-RPC surface for the runtime daemon.
2
+
3
+ Binds an ``asyncio`` Unix-domain socket at :attr:`DaemonConfig.unix_socket`
4
+ (typically ``$XDG_RUNTIME_DIR/alter.sock`` on Linux) with mode ``0o600`` and
5
+ speaks line-delimited JSON to connected clients.
6
+
7
+ Wire protocol
8
+ -------------
9
+
10
+ Every message (request or response) is a single UTF-8 line terminated with
11
+ ``\\n``. Requests are JSON-RPC 2.0-ish - we keep it minimal:
12
+
13
+ ::
14
+
15
+ {"method": "ping"} -> {"ok": true, "pong": true}
16
+ {"method": "auth", "token": "<t>"} -> {"ok": true, "authenticated": true}
17
+ {"method": "subscribe"} -> streams live events as they arrive
18
+ {"method": "ingest", "kind": "vault_consent_grant", "payload": {...}}
19
+ {"method": "whoami"} -> {"ok": true, "handle": "~foo"}
20
+ {"method": "agent/roster"} -> {"ok": true, "roster": [...instruments...]}
21
+
22
+ Streaming events (from a live ``subscribe``) are pushed as server-originated
23
+ frames of the form::
24
+
25
+ {"event": "identity.frame", "data": {...SSEFrame fields...}}
26
+
27
+ Closing the client socket ends the subscription. Disconnects during write
28
+ are swallowed.
29
+
30
+ Security
31
+ --------
32
+
33
+ The socket is created with the process umask tightened to ``0o077`` and then
34
+ ``os.chmod()``'d to ``0o600`` to keep every other UID off it. POSIX peer-cred
35
+ inspection is not required for cross-UID isolation - the ``0o600`` mode alone
36
+ is sufficient on Linux and macOS because neither permits other users to open
37
+ a socket they cannot read/write. Windows uses a Named Pipe instead (Wave 3).
38
+
39
+ Beyond the cross-UID gate, the server requires a token-based auth handshake
40
+ *within* the same UID - every method except ``ping`` is refused until the
41
+ client has presented the daemon's startup token. The token is minted at
42
+ ``run()`` and written to ``<socket_parent>/alter-daemon-token`` mode ``0o600``;
43
+ same-UID clients read the file and present the value as
44
+ ``{"method":"auth","token":"<t>"}`` immediately after connect. Auth failures
45
+ close the connection. This narrows the attack surface from "any same-UID
46
+ process can ingest events" to "any same-UID process that has read the token
47
+ file" - still bounded by the same trust floor as ``~/.ssh/id_rsa`` but with
48
+ an explicit audit boundary, an ``ingest`` ``kind`` whitelist, and rejection
49
+ of accidental connections from misconfigured tools.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import asyncio
55
+ import contextlib
56
+ import json
57
+ import logging
58
+ import os
59
+ import secrets
60
+ from dataclasses import dataclass, field
61
+ from datetime import datetime, timezone
62
+ from pathlib import Path
63
+ from typing import TYPE_CHECKING, Any
64
+
65
+ from alter_runtime.cap_cache import DaemonCapCache
66
+ from alter_runtime.config import DaemonConfig
67
+ from alter_runtime.daemon import Component
68
+ from alter_runtime.floor_loop import FloorState, is_safety_critical_call
69
+ from alter_runtime.subscribers.bus import EventBus
70
+ from alter_runtime.subscribers.sse import SSEFrame
71
+
72
+ if TYPE_CHECKING:
73
+ from alter_runtime.config import Session
74
+ from alter_runtime.subscribers.agent_frames import AgentFrameSubscriber
75
+
76
+ __all__ = ["UnixSocketServer"]
77
+
78
+ logger = logging.getLogger("alter_runtime.sockets.unix")
79
+
80
+ #: Maximum length of a single JSON-RPC request line. Large enough for a
81
+ #: typical ingest payload but small enough to prevent a runaway client from
82
+ #: exhausting memory.
83
+ MAX_LINE_BYTES: int = 256 * 1024
84
+
85
+ #: Topics to forward to subscribed clients.
86
+ FORWARDED_TOPICS: tuple[str, ...] = (
87
+ "identity.frame",
88
+ "identity.event",
89
+ "identity.connected",
90
+ "identity.disconnected",
91
+ )
92
+
93
+ #: Topic used for egress - clients that call ``ingest`` publish here for the
94
+ #: eventual ``LocalSignalForwarder`` (W2.2d) to POST to the DO.
95
+ EGRESS_TOPIC: str = "local.signal"
96
+
97
+ #: Filename (relative to the socket's parent directory) of the daemon-minted
98
+ #: auth token. Written ``0o600`` at server startup; same-UID clients read it
99
+ #: and present the value via ``{"method":"auth","token":"<t>"}``.
100
+ TOKEN_FILENAME: str = "alter-daemon-token"
101
+
102
+ #: ``ingest`` kinds external clients may publish onto the bus. Internal
103
+ #: subscribers (``GitWatcher``, ``EbpfSubscriber``, etc.) publish directly via
104
+ #: the in-process bus and never traverse this socket; the whitelist here is
105
+ #: scoped to what plugins / sibling MCP servers are permitted to inject.
106
+ INGEST_KIND_WHITELIST: frozenset[str] = frozenset(
107
+ {
108
+ "vault_consent_grant",
109
+ "vault_consent_revoke",
110
+ "vault_inference_emit",
111
+ }
112
+ )
113
+
114
+
115
+ @dataclass(eq=False)
116
+ class _ClientConnection:
117
+ """Per-client book-keeping.
118
+
119
+ ``eq=False`` keeps the default ``__hash__`` (object identity) so that
120
+ instances can live in a ``set()``. Structural equality between client
121
+ sessions has no meaning here - each connection is its own identity.
122
+ """
123
+
124
+ reader: asyncio.StreamReader
125
+ writer: asyncio.StreamWriter
126
+ subscribed: bool = False
127
+ #: ``True`` once the client has presented the daemon token via the
128
+ #: ``auth`` method. ``ping`` is the only method allowed before this flips;
129
+ #: every other method short-circuits with ``{"ok": false, "error":
130
+ #: "auth required"}`` and the connection is closed.
131
+ authenticated: bool = False
132
+ #: Queue used to fan out bus events to this specific client without
133
+ #: blocking the bus publisher.
134
+ queue: asyncio.Queue[dict[str, Any]] = field(default_factory=lambda: asyncio.Queue(maxsize=256))
135
+
136
+ def peer(self) -> str:
137
+ """Human-readable identifier for log lines."""
138
+ try:
139
+ info = self.writer.get_extra_info("sockname")
140
+ return str(info) if info else "<unix>"
141
+ except Exception: # pragma: no cover
142
+ return "<unix>"
143
+
144
+
145
+ class UnixSocketServer(Component):
146
+ """Local JSON-RPC server on a Unix-domain socket.
147
+
148
+ Parameters
149
+ ----------
150
+ config:
151
+ Loaded :class:`DaemonConfig` - only ``unix_socket`` is consulted.
152
+ bus:
153
+ Shared :class:`EventBus`. The server subscribes to identity topics
154
+ for fan-out and publishes to ``local.signal`` on ingest.
155
+ session:
156
+ Authenticated alter-cli :class:`Session` - used for the ``whoami``
157
+ method. Optional so tests can start the server without a session.
158
+ socket_path:
159
+ Optional override for the socket path (tests inject a ``tmp_path``
160
+ location so they don't collide with a real daemon).
161
+ auth_token:
162
+ Optional explicit auth token. When ``None`` (the production default),
163
+ :meth:`run` mints a fresh ``secrets.token_urlsafe(32)`` value at
164
+ startup and writes it next to the socket as ``alter-daemon-token``
165
+ (mode ``0o600``). Tests may inject a deterministic value to avoid
166
+ the disk write - when provided, no token file is created.
167
+ """
168
+
169
+ name = "unix_socket"
170
+
171
+ def __init__(
172
+ self,
173
+ config: DaemonConfig,
174
+ bus: EventBus,
175
+ session: Session | None = None,
176
+ *,
177
+ socket_path: Path | None = None,
178
+ auth_token: str | None = None,
179
+ agent_frames_subscriber: AgentFrameSubscriber | None = None,
180
+ floor_state: FloorState | None = None,
181
+ cap_cache: DaemonCapCache | None = None,
182
+ http_client: Any | None = None,
183
+ ) -> None:
184
+ self._config = config
185
+ self._bus = bus
186
+ self._session = session
187
+ self._socket_path: Path = socket_path if socket_path is not None else config.unix_socket
188
+ # Optional reference to the AgentFrameSubscriber that maintains the
189
+ # in-memory instrument roster. When present, the ``agent/roster``
190
+ # method returns the live roster without a DO round-trip. When absent,
191
+ # ``agent/roster`` returns an empty list so the daemon starts cleanly
192
+ # even when the subscriber has not been registered yet.
193
+ self._agent_frames_subscriber: AgentFrameSubscriber | None = agent_frames_subscriber
194
+ # D-MIN-VERSION-FLOOR-1 Phase 3 — shared floor state. When the
195
+ # FloorLoop reports below floor, every authenticated method except
196
+ # the ping/auth handshake and A15.3 safety-critical carve-outs
197
+ # short-circuits with the canonical ``client_below_floor`` envelope.
198
+ # When ``None`` (tests / degraded daemon) the gate is effectively
199
+ # a no-op — the server-side backend gate remains load-bearing.
200
+ self._floor_state: FloorState | None = floor_state
201
+ self._auth_token: str | None = auth_token
202
+ self._token_path: Path | None = None
203
+ self._token_minted: bool = False
204
+ self._server: asyncio.base_events.Server | None = None
205
+ self._clients: set[_ClientConnection] = set()
206
+ self._stop_event = asyncio.Event()
207
+ # Callback handles registered on the bus - kept so we can unsubscribe
208
+ # cleanly on stop.
209
+ self._bus_handlers: dict[str, Any] = {}
210
+ # Machine-wide capability and query cache shared across all clients.
211
+ # When ``None`` a fresh instance is created on first use. Tests may
212
+ # inject a pre-configured instance.
213
+ self._cap_cache: DaemonCapCache | None = cap_cache
214
+ # Optional HTTP client for cap_cache use. When provided the
215
+ # cap_cache's own client is overridden by passing it explicitly on
216
+ # each call so tests can inject a mock transport.
217
+ self._http_client: Any | None = http_client
218
+
219
+ # ------------------------------------------------------------------
220
+ # Component lifecycle
221
+ # ------------------------------------------------------------------
222
+
223
+ async def run(self) -> None:
224
+ """Bind the socket and serve clients until stop() is called."""
225
+ parent = self._socket_path.parent
226
+
227
+ # Pentest 2026-04-26 (HIGH): the parent directory holds the socket
228
+ # AND the daemon token file. mkdir(mode=0o700) only sets the mode
229
+ # on creation, not when the parent already exists. We must (a)
230
+ # create with 0o700, AND (b) chmod-fix any pre-existing parent so
231
+ # an inherited 0o755 from a non-XDG fallback (like /tmp/alter-1000/)
232
+ # cannot leak the token to other UIDs. Refuse outright when the
233
+ # fallback root is /tmp and XDG_RUNTIME_DIR is unset - /tmp itself
234
+ # is 1777 and a per-user subdir there is the wrong trust posture
235
+ # for shipping signed-frame credentials.
236
+ if not parent.exists():
237
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
238
+ else:
239
+ with contextlib.suppress(OSError):
240
+ os.chmod(parent, 0o700)
241
+ self._validate_parent_dir(parent)
242
+
243
+ # If a stale socket file exists (daemon crashed without cleanup),
244
+ # remove it. We only do this if it's an actual socket - never a
245
+ # regular file.
246
+ if self._socket_path.exists():
247
+ try:
248
+ mode = os.stat(self._socket_path).st_mode
249
+ except FileNotFoundError:
250
+ mode = 0
251
+ import stat as _stat
252
+
253
+ if _stat.S_ISSOCK(mode):
254
+ logger.info("unix_socket removing stale socket at %s", self._socket_path)
255
+ with contextlib.suppress(OSError):
256
+ self._socket_path.unlink()
257
+
258
+ logger.info("unix_socket binding path=%s", self._socket_path)
259
+
260
+ # Tighten the umask so the socket is created mode 0o600 even if the
261
+ # inherited shell umask is 0o022.
262
+ previous_umask = os.umask(0o077)
263
+ try:
264
+ self._server = await asyncio.start_unix_server(
265
+ self._handle_client,
266
+ path=str(self._socket_path),
267
+ )
268
+ finally:
269
+ os.umask(previous_umask)
270
+
271
+ # Post-bind chmod in case the kernel / fs didn't honour umask.
272
+ with contextlib.suppress(OSError):
273
+ os.chmod(self._socket_path, 0o600)
274
+
275
+ # Mint and persist the auth token if the caller didn't provide one.
276
+ # Same-UID clients read this file and present its value via the
277
+ # ``auth`` method before any other call is accepted.
278
+ if self._auth_token is None:
279
+ self._auth_token = secrets.token_urlsafe(32)
280
+ self._token_minted = True
281
+ self._token_path = self._socket_path.parent / TOKEN_FILENAME
282
+ previous_token_umask = os.umask(0o077)
283
+ try:
284
+ self._token_path.write_text(self._auth_token, encoding="utf-8")
285
+ finally:
286
+ os.umask(previous_token_umask)
287
+ with contextlib.suppress(OSError):
288
+ os.chmod(self._token_path, 0o600)
289
+ logger.info("unix_socket auth token written path=%s", self._token_path)
290
+
291
+ # Subscribe to the identity topics we fan out.
292
+ for topic in FORWARDED_TOPICS:
293
+ handler = self._make_forwarder(topic)
294
+ self._bus.subscribe(topic, handler)
295
+ self._bus_handlers[topic] = handler
296
+
297
+ try:
298
+ await self._stop_event.wait()
299
+ finally:
300
+ logger.info("unix_socket stopping path=%s", self._socket_path)
301
+ await self._shutdown()
302
+
303
+ async def stop(self) -> None:
304
+ """Cooperative shutdown."""
305
+ self._stop_event.set()
306
+
307
+ @staticmethod
308
+ def _validate_parent_dir(parent: Path) -> None:
309
+ """Refuse to bind the socket if the parent directory is unsafe.
310
+
311
+ Pentest 2026-04-26 (HIGH): the daemon token file lives next to the
312
+ socket - if its parent isn't user-owned and 0o700-or-tighter, any
313
+ same-UID-or-higher process can read the token and impersonate a
314
+ legitimate client. The XDG_RUNTIME_DIR location is already
315
+ per-user 0o700 by systemd convention; the legacy fallback
316
+ ``/tmp/alter-<uid>.sock`` puts the token directly in /tmp (1777)
317
+ which is the wrong trust posture for shipping. Refuse outright in
318
+ that case so operators see the misconfiguration instead of
319
+ silently inheriting a world-readable token directory.
320
+ """
321
+ # /tmp itself is 1777 by design - never permit it as the parent.
322
+ # Tests use tmp_path (/tmp/pytest-of-<user>/...) which has its
323
+ # own per-user owner; that case is allowed because the parent
324
+ # we check is the socket's *direct* parent, not /tmp.
325
+ if str(parent) == "/tmp":
326
+ raise PermissionError(
327
+ "unix_socket refusing to bind under /tmp directly - set "
328
+ "XDG_RUNTIME_DIR (e.g. /run/user/$UID) or override "
329
+ "ALTER_RUNTIME_SOCKET to a user-owned 0o700 directory."
330
+ )
331
+ if hasattr(os, "getuid"):
332
+ try:
333
+ stat_result = os.stat(parent)
334
+ except OSError as exc:
335
+ raise PermissionError(f"unix_socket cannot stat parent {parent}: {exc}") from exc
336
+ if stat_result.st_uid != os.getuid():
337
+ raise PermissionError(
338
+ f"unix_socket refusing to bind: parent {parent} is owned "
339
+ f"by uid={stat_result.st_uid}, expected uid={os.getuid()}."
340
+ )
341
+ mode_bits = stat_result.st_mode & 0o777
342
+ if mode_bits & 0o077:
343
+ raise PermissionError(
344
+ f"unix_socket refusing to bind: parent {parent} mode is "
345
+ f"{mode_bits:#o}, expected 0o700 (group/other bits unset)."
346
+ )
347
+
348
+ async def _shutdown(self) -> None:
349
+ """Close the server, drop subscribers, kick connected clients."""
350
+ for topic, handler in list(self._bus_handlers.items()):
351
+ self._bus.unsubscribe(topic, handler)
352
+ self._bus_handlers.clear()
353
+
354
+ if self._server is not None:
355
+ self._server.close()
356
+ with contextlib.suppress(Exception):
357
+ await self._server.wait_closed()
358
+ self._server = None
359
+
360
+ for client in list(self._clients):
361
+ with contextlib.suppress(Exception):
362
+ client.writer.close()
363
+
364
+ # Remove the socket file so the next run() call starts clean.
365
+ with contextlib.suppress(FileNotFoundError, OSError):
366
+ self._socket_path.unlink()
367
+
368
+ # Remove the daemon-minted token file. Tokens injected via the
369
+ # constructor (tests, embedders) are not on disk and not our problem.
370
+ if self._token_minted and self._token_path is not None:
371
+ with contextlib.suppress(FileNotFoundError, OSError):
372
+ self._token_path.unlink()
373
+ self._token_minted = False
374
+ self._token_path = None
375
+
376
+ # ------------------------------------------------------------------
377
+ # Connection handling
378
+ # ------------------------------------------------------------------
379
+
380
+ async def _handle_client(
381
+ self,
382
+ reader: asyncio.StreamReader,
383
+ writer: asyncio.StreamWriter,
384
+ ) -> None:
385
+ """Per-client coroutine - serves one connection until it closes."""
386
+ client = _ClientConnection(reader=reader, writer=writer)
387
+ self._clients.add(client)
388
+ logger.debug("unix_socket client connected peer=%s", client.peer())
389
+
390
+ # Kick off the fan-out pump in parallel; it drains `client.queue`.
391
+ pump_task = asyncio.create_task(self._pump_queue(client))
392
+
393
+ try:
394
+ while not self._stop_event.is_set():
395
+ try:
396
+ line = await reader.readuntil(b"\n")
397
+ except asyncio.IncompleteReadError:
398
+ break
399
+ except asyncio.LimitOverrunError:
400
+ # Client sent > 64KB without a newline - drain the rest
401
+ # of the stream and hang up.
402
+ logger.warning("unix_socket client line too long peer=%s", client.peer())
403
+ break
404
+ if not line or len(line) > MAX_LINE_BYTES:
405
+ break
406
+ await self._dispatch_line(client, line)
407
+ except ConnectionError:
408
+ pass
409
+ finally:
410
+ pump_task.cancel()
411
+ with contextlib.suppress(asyncio.CancelledError, Exception):
412
+ await pump_task
413
+ with contextlib.suppress(Exception):
414
+ writer.close()
415
+ await writer.wait_closed()
416
+ self._clients.discard(client)
417
+ logger.debug("unix_socket client disconnected peer=%s", client.peer())
418
+
419
+ async def _dispatch_line(self, client: _ClientConnection, line: bytes) -> None:
420
+ """Parse one JSON-RPC request line and dispatch it."""
421
+ try:
422
+ request = json.loads(line.decode("utf-8", errors="replace"))
423
+ except json.JSONDecodeError as exc:
424
+ await self._write(client, {"ok": False, "error": f"invalid json: {exc}"})
425
+ return
426
+
427
+ if not isinstance(request, dict):
428
+ await self._write(client, {"ok": False, "error": "request must be a JSON object"})
429
+ return
430
+
431
+ method = request.get("method")
432
+
433
+ # ``ping`` is the only method allowed pre-auth; it serves as a
434
+ # liveness probe for clients that haven't read the token yet.
435
+ if method == "ping":
436
+ await self._write(client, {"ok": True, "pong": True})
437
+ return
438
+
439
+ # ``auth`` performs the token handshake. Constant-time compare so a
440
+ # malicious same-UID process can't time-side-channel the token.
441
+ if method == "auth":
442
+ token = request.get("token")
443
+ expected = self._auth_token
444
+ if (
445
+ not isinstance(token, str)
446
+ or expected is None
447
+ or not secrets.compare_digest(token, expected)
448
+ ):
449
+ logger.warning(
450
+ "unix_socket auth failed peer=%s - closing connection",
451
+ client.peer(),
452
+ )
453
+ await self._write(client, {"ok": False, "error": "auth: invalid token"})
454
+ with contextlib.suppress(Exception):
455
+ client.writer.close()
456
+ return
457
+ client.authenticated = True
458
+ await self._write(client, {"ok": True, "authenticated": True})
459
+ return
460
+
461
+ # Every other method requires prior auth. Refuse and close on first
462
+ # offence - a same-UID process that doesn't speak the protocol is
463
+ # almost certainly misconfigured rather than malicious, but either
464
+ # way it has no business holding the connection open.
465
+ if not client.authenticated:
466
+ await self._write(client, {"ok": False, "error": "auth required"})
467
+ with contextlib.suppress(Exception):
468
+ client.writer.close()
469
+ return
470
+
471
+ # D-MIN-VERSION-FLOOR-1 Phase 3 — floor gate.
472
+ #
473
+ # Locked → emit the canonical ``client_below_floor`` envelope on
474
+ # every authenticated method, except the A15.3 safety-critical
475
+ # carve-out (``ingest`` with ``urgency: critical|emergency``). The
476
+ # response body is byte-shape-equal to the Phase 1 server middleware
477
+ # reject (``backend/app/middleware/min_version_floor.py:_envelope``):
478
+ # ``{"error": {"code": "client_below_floor", "message": "...",
479
+ # "client_version": "...", "min_version": "...", "upgrade_cmd":
480
+ # "...", "channel": "..."}}``.
481
+ #
482
+ # The handshake (``ping``, ``auth``) is permitted regardless of
483
+ # floor state — analogous to the MCP ``initialize`` permitted in
484
+ # DR §6 so callers see a clean error envelope rather than a TCP
485
+ # reset. Both methods exited above this point.
486
+ if self._floor_state is not None and self._floor_state.is_locked():
487
+ if not is_safety_critical_call(method, request):
488
+ envelope = self._floor_state.envelope_payload()
489
+ if envelope is not None:
490
+ floor_response: dict[str, Any] = {"ok": False, **envelope}
491
+ await self._write(client, floor_response)
492
+ return
493
+
494
+ if method == "whoami":
495
+ await self._write(
496
+ client,
497
+ {
498
+ "ok": True,
499
+ "handle": self._session.handle if self._session else None,
500
+ "consent_tier": self._session.consent_tier if self._session else None,
501
+ },
502
+ )
503
+ elif method == "agent/roster":
504
+ # D-AGENT-CHANNEL-1 §8 — local read of current online instruments
505
+ # without a DO round-trip. Delegates to the AgentFrameSubscriber's
506
+ # in-memory roster which is populated from observed agent_frame
507
+ # deliveries. Returns an empty list when the subscriber has not
508
+ # been wired (daemon degraded mode or subscriber not yet registered).
509
+ roster: list[Any] = []
510
+ if self._agent_frames_subscriber is not None:
511
+ try:
512
+ roster = self._agent_frames_subscriber.get_roster()
513
+ except Exception as exc: # noqa: BLE001 — defensive
514
+ logger.warning("unix_socket agent/roster: roster read failed: %s", exc)
515
+ await self._write(client, {"ok": True, "roster": roster})
516
+ elif method == "subscribe":
517
+ client.subscribed = True
518
+ await self._write(client, {"ok": True, "subscribed": True})
519
+ elif method == "unsubscribe":
520
+ client.subscribed = False
521
+ await self._write(client, {"ok": True, "subscribed": False})
522
+ elif method == "ingest":
523
+ kind = request.get("kind")
524
+ payload = request.get("payload", {})
525
+ if not isinstance(kind, str) or not isinstance(payload, dict):
526
+ await self._write(
527
+ client,
528
+ {"ok": False, "error": "ingest requires kind:str and payload:dict"},
529
+ )
530
+ return
531
+ if kind not in INGEST_KIND_WHITELIST:
532
+ await self._write(
533
+ client,
534
+ {"ok": False, "error": f"ingest kind not permitted: {kind!r}"},
535
+ )
536
+ return
537
+ # Mint per-kind side-effects (event_id / revocation_token /
538
+ # timestamps) before publishing so the bus payload carries the
539
+ # same identifiers the client receives in the response. The
540
+ # plaintext revocation_token is rendered ONCE in the plugin's
541
+ # Notice modal - only its SHA-256 hash is persisted client-side
542
+ # and in the backend ledger. The daemon forwards the plaintext
543
+ # to the backend so the ledger can compute the hash on receipt.
544
+ response: dict[str, Any] = {"ok": True, "ingested": True}
545
+ enriched_payload: dict[str, Any] = dict(payload)
546
+ if kind == "vault_consent_grant":
547
+ event_id = "evt-" + secrets.token_hex(8)
548
+ revocation_token = secrets.token_urlsafe(32)
549
+ granted_at = datetime.now(tz=timezone.utc).isoformat()
550
+ response["event_id"] = event_id
551
+ response["revocation_token"] = revocation_token
552
+ response["granted_at"] = granted_at
553
+ enriched_payload["event_id"] = event_id
554
+ enriched_payload["revocation_token"] = revocation_token
555
+ enriched_payload["granted_at"] = granted_at
556
+ elif kind == "vault_consent_revoke":
557
+ revoked_at = datetime.now(tz=timezone.utc).isoformat()
558
+ response["revoked"] = True
559
+ response["revoked_at"] = revoked_at
560
+ enriched_payload["revoked_at"] = revoked_at
561
+ await self._bus.publish(
562
+ EGRESS_TOPIC,
563
+ {
564
+ "kind": kind,
565
+ "payload": enriched_payload,
566
+ "source": "unix_socket",
567
+ },
568
+ )
569
+ await self._write(client, response)
570
+ elif method == "cap.get":
571
+ # Machine-wide cap-JWT cache. Collapses N CC-bridge cap-mint
572
+ # calls into one minting identity per handle-scope set.
573
+ #
574
+ # Request: {"method": "cap.get", "scopes": ["scope1", ...]}
575
+ # Response: {"ok": true, "capability": "<jwt>", "expires_at": "<iso>"}
576
+ # or {"ok": false, "error": "<reason>"}
577
+ scopes = request.get("scopes")
578
+ if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
579
+ await self._write(
580
+ client,
581
+ {"ok": False, "error": "cap.get requires scopes: string[]"},
582
+ )
583
+ return
584
+ cache = self._ensure_cap_cache()
585
+ http = self._http_client
586
+ result = await cache.get_cap(scopes, client=http)
587
+ await self._write(client, result)
588
+ elif method == "query.get":
589
+ # Cap-gated query cache. Single GET per (path, params) every 15 s.
590
+ #
591
+ # Request: {"method": "query.get", "path": "<str>", "params": {...}}
592
+ # Response: {"ok": true, "body": <any>, "cached_at": <float>}
593
+ # or {"ok": false, "error": "<reason>"}
594
+ path = request.get("path")
595
+ params = request.get("params")
596
+ if not isinstance(path, str) or not path:
597
+ await self._write(
598
+ client,
599
+ {"ok": False, "error": "query.get requires path: str"},
600
+ )
601
+ return
602
+ if params is not None and not isinstance(params, dict):
603
+ await self._write(
604
+ client,
605
+ {"ok": False, "error": "query.get params must be an object or absent"},
606
+ )
607
+ return
608
+ cache = self._ensure_cap_cache()
609
+ http = self._http_client
610
+ result = await cache.get_query(path, params, client=http)
611
+ await self._write(client, result)
612
+ else:
613
+ await self._write(
614
+ client,
615
+ {"ok": False, "error": f"unknown method: {method!r}"},
616
+ )
617
+
618
+ async def _write(self, client: _ClientConnection, obj: dict[str, Any]) -> None:
619
+ """Serialise ``obj`` as a single JSON line and send it."""
620
+ try:
621
+ body = (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
622
+ client.writer.write(body)
623
+ await client.writer.drain()
624
+ except (ConnectionError, OSError, RuntimeError):
625
+ # Client went away - swallow, the reader loop will clean up.
626
+ pass
627
+
628
+ async def _pump_queue(self, client: _ClientConnection) -> None:
629
+ """Drain the client's fan-out queue onto the wire."""
630
+ while True:
631
+ frame = await client.queue.get()
632
+ if not client.subscribed:
633
+ continue
634
+ await self._write(client, frame)
635
+
636
+ # ------------------------------------------------------------------
637
+ # Bus fan-out
638
+ # ------------------------------------------------------------------
639
+
640
+ def _make_forwarder(self, topic: str):
641
+ """Return a bus subscriber that enqueues events for every client."""
642
+
643
+ async def _forwarder(payload: Any) -> None:
644
+ frame: dict[str, Any]
645
+ if isinstance(payload, SSEFrame):
646
+ frame = {
647
+ "event": topic,
648
+ "data": {
649
+ "event": payload.event,
650
+ "data": payload.data,
651
+ "id": payload.id,
652
+ },
653
+ }
654
+ else:
655
+ frame = {"event": topic, "data": payload}
656
+ for client in list(self._clients):
657
+ if not client.subscribed:
658
+ continue
659
+ try:
660
+ client.queue.put_nowait(frame)
661
+ except asyncio.QueueFull:
662
+ logger.warning(
663
+ "unix_socket client queue full - dropping frame peer=%s",
664
+ client.peer(),
665
+ )
666
+
667
+ return _forwarder
668
+
669
+ # ------------------------------------------------------------------
670
+ # Cap/query cache helpers
671
+ # ------------------------------------------------------------------
672
+
673
+ def _ensure_cap_cache(self) -> DaemonCapCache:
674
+ """Return the shared :class:`DaemonCapCache`, constructing it on first call."""
675
+ if self._cap_cache is None:
676
+ self._cap_cache = DaemonCapCache(self._session)
677
+ return self._cap_cache
678
+
679
+ # ------------------------------------------------------------------
680
+ # Test introspection
681
+ # ------------------------------------------------------------------
682
+
683
+ @property
684
+ def socket_path(self) -> Path:
685
+ return self._socket_path
686
+
687
+ @property
688
+ def token_path(self) -> Path | None:
689
+ """Filesystem path of the daemon-minted token, or ``None`` if the
690
+ token was injected via the constructor (tests / embedders) and never
691
+ written to disk."""
692
+ return self._token_path
693
+
694
+ @property
695
+ def auth_token(self) -> str | None:
696
+ """The current auth token. Exposed for tests and same-process
697
+ embedders; production clients read it from :attr:`token_path`."""
698
+ return self._auth_token
699
+
700
+ @property
701
+ def client_count(self) -> int:
702
+ return len(self._clients)