"""External event ingress for the Textual app. Exposes a small `EventSource` protocol plus a Unix-domain-socket implementation that lets local processes push commands, prompts, and signals into a running session over a newline-delimited JSON wire protocol. !!! warning "Experimental" The wire format and configuration env vars may change without semver guarantees while this surface stabilizes. """ from __future__ import annotations import asyncio import contextlib import json import logging import os import stat import tempfile from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Literal, Protocol, get_args from deepagents_code.command_registry import BypassTier if TYPE_CHECKING: from collections.abc import Awaitable, Callable logger = logging.getLogger(__name__) ExternalEventKind = Literal["command", "prompt", "signal"] """Top-level event kinds carried by the wire protocol.""" ExternalSignal = Literal["interrupt", "force-clear"] """Closed vocabulary of `kind="signal"` payloads accepted by the listener.""" EventSink = "Callable[[ExternalEvent], Awaitable[None]]" """Type alias for the async callback that receives parsed events.""" _VALID_KINDS: frozenset[str] = frozenset(get_args(ExternalEventKind)) _VALID_SIGNALS: frozenset[str] = frozenset(get_args(ExternalSignal)) _ACK = b'{"ok":true}\n' """Wire-level acknowledgement returned for an accepted event.""" _MAX_LINE_BYTES = 64 * 1024 """Per-line read limit; an oversized line is rejected with a NACK.""" _CLIENT_IDLE_TIMEOUT_SECONDS = 60.0 """Maximum idle time on a client connection before the server closes it.""" @dataclass(frozen=True, slots=True, kw_only=True) class ExternalEvent: """A transport-independent event delivered from outside the TUI.""" kind: ExternalEventKind payload: str source: str bypass: BypassTier = BypassTier.QUEUED correlation_id: str | None = None def __post_init__(self) -> None: """Validate invariants for direct construction. Raises: ValueError: If `kind` is not a known kind, the payload is empty or whitespace-only, or the kind is `"signal"` but the payload is not a recognized signal name. """ if self.kind not in _VALID_KINDS: msg = f"Unknown external event kind: {self.kind!r}" raise ValueError(msg) if not self.payload or not self.payload.strip(): msg = "External event payload must be a non-empty string" raise ValueError(msg) if self.kind == "signal" and self.payload.strip().lower() not in _VALID_SIGNALS: msg = ( f"Unknown external signal: {self.payload!r}; " f"expected one of {sorted(_VALID_SIGNALS)}" ) raise ValueError(msg) class EventSource(Protocol): """Source of external events for the Textual app. Implementations must be safe to `stop()` even when `start()` failed partway through; the app always invokes `stop()` from a `finally` block. """ async def start( self, sink: Callable[[ExternalEvent], Awaitable[None]], ) -> None: """Start forwarding events to `sink`. Args: sink: Async callback that receives parsed external events. """ async def serve_forever(self) -> None: """Park until the source is cancelled or its transport dies. Implementations should re-raise `CancelledError` and propagate unexpected transport-layer failures so the lifecycle owner can notice and surface them. """ async def stop(self) -> None: """Stop forwarding events and release transport resources.""" class UnixSocketEventSource: """Line-delimited JSON event source over a local Unix domain socket. The listener creates its parent directory with mode `0o700` and binds the socket inside it under a transient `umask(0o077)` so the socket inherits `0o600` from the moment of `bind()`. Stale sockets at the configured path are removed on start, but only after a `stat` confirms the path is a socket — a regular file or directory at that path is left untouched and causes start to fail loudly. """ def __init__(self, path: Path | None = None) -> None: """Create a Unix-socket event source. Args: path: Socket path. When omitted, a per-process path under the runtime or temp directory is used. """ self.path = path or default_unix_socket_path() self._server: asyncio.AbstractServer | None = None self._sink: Callable[[ExternalEvent], Awaitable[None]] | None = None async def start( self, sink: Callable[[ExternalEvent], Awaitable[None]], ) -> None: """Start listening for newline-delimited JSON events. Args: sink: Async callback invoked with each decoded event. Raises: RuntimeError: If `start()` has already been called on this instance without a subsequent `stop()`. FileExistsError: If the socket path is occupied by a non-socket filesystem entry. OSError: If the socket cannot be bound (e.g. permission denied, path too long). """ # noqa: DOC502 # FileExistsError/OSError propagate from helpers if self._server is not None: msg = "UnixSocketEventSource is already started" raise RuntimeError(msg) self._sink = sink self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with contextlib.suppress(FileNotFoundError): _unlink_existing_socket(self.path) previous_umask = os.umask(0o077) try: self._server = await asyncio.start_unix_server( self._handle_client, path=str(self.path), limit=_MAX_LINE_BYTES, ) finally: os.umask(previous_umask) # Defense in depth: even if `start_unix_server` somehow ignored the # umask (different libc, mocked socket layer), force-tighten the mode. with contextlib.suppress(OSError): self.path.chmod(0o600) logger.debug("External event listener bound at %s", self.path) async def serve_forever(self) -> None: """Park until the underlying server is cancelled or fails. `asyncio.start_unix_server` already begins accepting connections, so this delegates to the server's own `serve_forever`. A fatal error inside the accept loop propagates here, letting the lifecycle owner notice and react. Raises: RuntimeError: If invoked before `start()`. """ if self._server is None: msg = "UnixSocketEventSource.serve_forever called before start()" raise RuntimeError(msg) await self._server.serve_forever() async def stop(self) -> None: """Close the listener and remove the socket path. Idempotent: safe to call after a failed or never-started `start()`. """ server = self._server self._server = None if server is not None: server.close() with contextlib.suppress(Exception): await server.wait_closed() try: _unlink_existing_socket(self.path) except FileNotFoundError: pass except FileExistsError as exc: logger.warning( "Leaving non-socket entry at %s during shutdown: %s", self.path, exc, ) except OSError as exc: logger.warning("Failed to unlink event socket %s: %s", self.path, exc) async def _handle_client( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: r"""Read newline-delimited JSON envelopes from one client. Each accepted line yields an event to the configured sink and is acked with `{"ok":true}\n` (plus `correlation_id` when present). Rejected lines (oversized, malformed, sink unconfigured, sink raised) are answered with `{"ok":false,"error":...}\n` and the loop continues so a single bad caller cannot kill the connection. """ peer = writer.get_extra_info("peername") or "" logger.debug("External event client connected: %s", peer) try: while True: try: line = await asyncio.wait_for( reader.readline(), timeout=_CLIENT_IDLE_TIMEOUT_SECONDS, ) except TimeoutError: logger.debug("Closing idle external event client %s", peer) break except (ValueError, asyncio.LimitOverrunError) as exc: logger.warning("External event line exceeded read limit: %s", exc) await _write_nack(writer, "line exceeds read limit", None) break if not line: break await self._handle_one_line(line, writer) except (BrokenPipeError, ConnectionResetError) as exc: logger.debug("External event client %s disconnected: %s", peer, exc) finally: writer.close() with contextlib.suppress(Exception): await writer.wait_closed() logger.debug("External event client closed: %s", peer) async def _handle_one_line( self, line: bytes, writer: asyncio.StreamWriter, ) -> None: """Decode and dispatch one envelope, replying with ACK or NACK.""" correlation_id: str | None = None try: event = decode_external_event(line, source=f"unix:{self.path}") except (ValueError, TypeError) as exc: logger.warning("Rejected malformed external event: %s", exc) with contextlib.suppress(ValueError, TypeError, json.JSONDecodeError): parsed = json.loads(line) if isinstance(parsed, dict): candidate = parsed.get("correlation_id") if isinstance(candidate, str): correlation_id = candidate await _write_nack(writer, str(exc), correlation_id) return correlation_id = event.correlation_id if self._sink is None: logger.warning("External event arrived before sink was set; dropping") await _write_nack(writer, "listener not ready", correlation_id) return try: await self._sink(event) except Exception as exc: logger.exception("External event sink raised") await _write_nack(writer, f"sink failed: {exc}", correlation_id) return await _write_ack(writer, correlation_id) async def _write_ack( writer: asyncio.StreamWriter, correlation_id: str | None, ) -> None: """Write the success acknowledgement frame, echoing `correlation_id`.""" if correlation_id is None: writer.write(_ACK) else: body = json.dumps({"ok": True, "correlation_id": correlation_id}) writer.write(body.encode("utf-8") + b"\n") with contextlib.suppress(BrokenPipeError, ConnectionResetError): await writer.drain() async def _write_nack( writer: asyncio.StreamWriter, error: str, correlation_id: str | None, ) -> None: """Write a failure response frame; never raises on a closed socket.""" body: dict[str, object] = {"ok": False, "error": error} if correlation_id is not None: body["correlation_id"] = correlation_id try: writer.write(json.dumps(body).encode("utf-8") + b"\n") await writer.drain() except (BrokenPipeError, ConnectionResetError): pass def default_unix_socket_path() -> Path: """Return the default per-process Unix socket path. Prefers `XDG_RUNTIME_DIR` (per-user, tmpfs-backed, auto-cleaned on logout) and falls back to the system temp dir when the runtime directory is unset. """ root = os.environ.get("XDG_RUNTIME_DIR") base = Path(root) if root else Path(tempfile.gettempdir()) return base / "deepagents" / f"events-{os.getpid()}.sock" def _unlink_existing_socket(path: Path) -> None: """Remove a stale Unix socket without touching other filesystem entries. Args: path: Candidate socket path to remove. Raises: FileNotFoundError: If `path` does not exist. FileExistsError: If `path` exists but is not a Unix socket. OSError: If the entry exists but cannot be removed. """ # noqa: DOC502 # FileNotFoundError/OSError propagate from stat/unlink info = path.stat(follow_symlinks=False) if not stat.S_ISSOCK(info.st_mode): msg = f"Refusing to remove non-socket external event path: {path}" raise FileExistsError(msg) path.unlink() def decode_external_event(data: bytes, *, source: str) -> ExternalEvent: """Decode one newline-delimited JSON external event. Args: data: Raw JSON line. source: Transport-specific source label attached to the event. Returns: Parsed external event. Raises: TypeError: If the envelope is not a JSON object. ValueError: If any envelope field is missing, of the wrong type, or otherwise invalid. """ try: raw = json.loads(data) except json.JSONDecodeError as exc: msg = f"External event must be valid JSON: {exc.msg}" raise ValueError(msg) from exc if not isinstance(raw, dict): msg = "External event must be a JSON object" raise TypeError(msg) kind = raw.get("kind") if kind not in _VALID_KINDS: msg = f"External event kind must be one of {sorted(_VALID_KINDS)}; got {kind!r}" raise ValueError(msg) payload = raw.get("payload") if not isinstance(payload, str) or not payload.strip(): msg = "External event payload must be a non-empty string" raise ValueError(msg) bypass = raw.get("bypass", BypassTier.QUEUED.value) try: bypass_tier = BypassTier(bypass) except ValueError as exc: msg = f"External event bypass must be a valid bypass tier: {exc}" raise ValueError(msg) from exc correlation_id = raw.get("correlation_id") if correlation_id is not None and not isinstance(correlation_id, str): msg = "External event correlation_id must be a string when present" raise ValueError(msg) return ExternalEvent( kind=kind, payload=payload, source=source, bypass=bypass_tier, correlation_id=correlation_id, )