diff --git a/src/cleveragents/lsp/transport.py b/src/cleveragents/lsp/transport.py index 841475b1c..41063d16f 100644 --- a/src/cleveragents/lsp/transport.py +++ b/src/cleveragents/lsp/transport.py @@ -27,6 +27,8 @@ from typing import Any import structlog +from cleveragents.lsp.errors import LspError + logger = structlog.get_logger(__name__) # Maximum time (seconds) to wait for a graceful process termination @@ -112,36 +114,15 @@ class StdioTransport: cwd=self._cwd, ) except FileNotFoundError as exc: - self._process = None - - from cleveragents.lsp.errors import LspError - raise LspError( f"LSP server command not found: {self._command}", details={"command": self._command, "args": self._args}, ) from exc except OSError as exc: - # Popen may have partially started the subprocess before - # raising (e.g. execve failure post-fork on some platforms). - # Ensure cleanup so the process does not leak into the caller's - # address space. - if self._process is not None: - self.stop() - self._process = None - - from cleveragents.lsp.errors import LspError - raise LspError( f"Failed to start LSP server: {exc}", details={"command": self._command, "error": str(exc)}, ) from exc - except Exception: - # Catch-all for any other low-level OSError / resource-error - # variants that might leave a zombie process behind. - if self._process is not None: - self.stop() - self._process = None - raise logger.info( "lsp.transport.started", @@ -239,7 +220,23 @@ class StdioTransport: return self._read_one_message(effective_timeout) def _read_one_message(self, timeout: float) -> dict[str, Any] | None: - """Parse a single ``Content-Length`` framed JSON-RPC message.""" + """Parse a single ``Content-Length``-framed JSON-RPC message. + + Header lines are decoded with ``errors="strict"``; any non-ASCII byte + raises :class:`~cleveragents.lsp.errors.LspError`. An additional + printable-ASCII guard rejects decoded headers containing characters + outside the codepoint range 0x20 (space) to 0x7E (tilde). + + Returns + ------- + dict[str, Any] | None + Parsed JSON body, or ``None`` on EOF / timeout. + + Raises + ------ + LspError + If a header contains non-ASCII bytes or non-printable characters. + """ assert self._process is not None assert self._process.stdout is not None @@ -254,7 +251,21 @@ class StdioTransport: line = stdout.readline() if not line: return None # EOF — server exited - decoded = line.decode("ascii", errors="replace").strip() + try: + decoded = line.decode("ascii", errors="strict").strip() + except UnicodeDecodeError as exc: + raise LspError( + f"LSP header contains non-ASCII bytes: {exc}", + details={"raw_header": repr(line)}, + ) from exc + # Printable-ASCII guard: reject control characters + # outside the range 0x20 (space) through 0x7E (tilde). + if not all(0x20 <= ord(c) <= 0x7E for c in decoded): + raise LspError( + f"LSP header contains non-printable ASCII characters: " + f"{decoded!r}", + details={"raw_header": repr(line)}, + ) if not decoded: break # Empty line = end of headers if decoded.lower().startswith("content-length:"):