diff --git a/README.md b/README.md index b4e109f..7893809 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ A terminal is **interactive shell access on the host**. This plugin: ## Requirements - **protoAgent ≥ 0.27.0** (console views + WebSocket-through-the-fleet-proxy, #883). -- A **Unix PTY** (Linux/macOS). Windows is not supported (no `pywinpty` yet). -- No pip deps (the PTY is stdlib). xterm.js + addons are **vendored** (`vendor/`) and - served locally by the plugin — **works offline / airgapped**, no CDN. +- **Linux/macOS** — stdlib PTY, no pip deps. **Windows is EXPERIMENTAL** (untested in + CI): it uses `pywinpty` — `python -m server plugin install-deps terminal` on Windows, + then validate. The POSIX path is the supported, tested one. +- xterm.js + addons are **vendored** (`vendor/`) and served locally by the plugin — + **works offline / airgapped**, no CDN. ## Install — no restart needed @@ -77,15 +79,16 @@ Then open the **Terminal** rail icon. (Make sure the host has an operator bearer | File | What | |---|---| -| `pty_session.py` | the stdlib-`pty` shell session (spawn / read / write / resize / reap) | -| `api.py` | the router: the public `/view` page + the bearer-gated `/ws` PTY bridge | +| `pty_session.py` | the PTY shell session: POSIX (stdlib `pty`) + Windows (`pywinpty`, experimental) behind `open_session()` | +| `api.py` | the router: the public `/view` page, vendored `/static/*` assets, the bearer-gated `/ws` PTY bridge | | `view.py` | the xterm.js page — four rules + the `--pl-*` → xterm theme mapping | +| `vendor/` | the vendored xterm.js + addons + css (served offline) | | `__init__.py` | `register()` — mounts the one router | ## Roadmap A solid single terminal session per view. Possible next steps: multi-session tabs, -split panes, a search overlay, Windows (`pywinpty`). PRs welcome. +split panes, a search overlay, and validating the experimental Windows backend. PRs welcome. Enabled by default once installed (the WS bearer gate is the protection) — disable with `plugins.disabled: [terminal]`. diff --git a/api.py b/api.py index 6090c98..97eb1d1 100644 --- a/api.py +++ b/api.py @@ -22,7 +22,7 @@ from fastapi import WebSocket # module-level so the websocket route's annotation resolves -from .pty_session import PtySession +from .pty_session import open_session from .view import PAGE log = logging.getLogger("protoagent.plugins.terminal") @@ -106,7 +106,7 @@ async def _bridge(ws, *, shell: str, cwd: str) -> None: """Bridge a WebSocket to a fresh PTY for its lifetime.""" from fastapi import WebSocketDisconnect - sess = PtySession(shell=shell, cwd=cwd, scrub_env=scrub_keys()) + sess = open_session(shell=shell, cwd=cwd, scrub_env=scrub_keys()) try: sess.start() except Exception as exc: # noqa: BLE001 diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index e305954..b78ce4c 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,6 +1,6 @@ id: terminal name: Terminal -version: 0.2.0 +version: 0.3.0 description: >- A full terminal in the protoAgent console — an xterm.js view wired to a real PTY shell over a WebSocket. The terminal is themed from the protoAgent design system @@ -13,6 +13,11 @@ enabled: true repository: https://github.com/protoLabsAI/terminal-plugin min_protoagent_version: "0.27.0" # plugin console views (ADR 0026) + WS-through-proxy (#883) +# Linux/macOS use the stdlib PTY (no deps). Windows is EXPERIMENTAL (untested in CI) +# and needs pywinpty — declared, NOT auto-installed (install-deps it on Windows). +requires_pip: + - "pywinpty>=2.0; sys_platform == 'win32'" + config_section: terminal config: shell: "" # the shell to spawn; blank → $SHELL, then /bin/bash diff --git a/pty_session.py b/pty_session.py index 1d1bfef..b656a6c 100644 --- a/pty_session.py +++ b/pty_session.py @@ -1,22 +1,31 @@ -"""A PTY-backed shell session — stdlib only (``pty``/``os``/``fcntl``/``termios``), -Unix-first (Linux/macOS). +"""A PTY-backed shell session. -No protoAgent host imports and no pip deps, so the suite spawns real PTYs in CI. The -session owns a child shell behind a pseudo-terminal: read its output off the master -fd (in a thread, so the event loop never blocks), write keystrokes to it, resize it -(``TIOCSWINSZ``), and reap the process group on close. +Two backends behind one interface (start / read / write / resize / poll / aclose): +- ``PtySession`` — POSIX (Linux/macOS), stdlib only (``pty``/``os``/``fcntl``/ + ``termios``); no pip deps, so the suite spawns real PTYs in CI. +- ``WinPtySession`` — Windows, via the optional ``pywinpty`` package (EXPERIMENTAL, + untested in our Linux CI — see ``requires_pip`` in the manifest). + +``open_session(...)`` picks the right backend for the platform. The POSIX session owns +a child shell behind a pseudo-terminal: read its output off the master fd (in a thread, +so the loop never blocks), write keystrokes, resize (``TIOCSWINSZ``), reap the group. """ from __future__ import annotations import asyncio import errno -import fcntl import os -import pty import signal import struct -import termios +import sys + +# Unix PTY primitives — guarded so the module still imports on Windows (which uses the +# pywinpty backend). The POSIX PtySession references these only at runtime, on POSIX. +if sys.platform != "win32": + import fcntl + import pty + import termios # Default TERM env so colour + 256-colour CLIs behave inside the terminal. _TERM_ENV = { @@ -188,3 +197,104 @@ def _reap(self, pid: int) -> None: self.pid = None except OSError: self.pid = None + + +class WinPtySession: + """Windows backend via the optional ``pywinpty`` package — EXPERIMENTAL (untested + in our Linux CI; needs a Windows validator). Same interface as ``PtySession``, + but on top of ``winpty.PtyProcess`` (method-based read/write, not an fd).""" + + def __init__( + self, + *, + shell: str = "", + cwd: str = "", + cols: int = 80, + rows: int = 24, + env_overrides: dict[str, str] | None = None, + scrub_env: list[str] | None = None, + ): + self.shell = shell or os.environ.get("COMSPEC") or "cmd.exe" + self.cwd = cwd or os.getcwd() + self.cols = max(1, int(cols)) + self.rows = max(1, int(rows)) + self._env_overrides = env_overrides or {} + self._scrub_env = set(scrub_env or []) + self.pid: int | None = None + self._proc = None + self._exit_code: int | None = None + + def _build_env(self) -> dict[str, str]: + env = {k: v for k, v in os.environ.items() if k not in self._scrub_env} + env.update(_TERM_ENV) + env.update(self._env_overrides) + return env + + def start(self) -> None: + try: + from winpty import PtyProcess # optional dep (requires_pip on Windows) + except ImportError as exc: + raise PtyError("pywinpty not installed — `pip install pywinpty` (Windows)") from exc + self._proc = PtyProcess.spawn( + self.shell, cwd=self.cwd or None, env=self._build_env(), dimensions=(self.rows, self.cols) + ) + self.pid = getattr(self._proc, "pid", None) + + async def read(self, n: int = 65536) -> bytes: + if self._proc is None: + return b"" + loop = asyncio.get_running_loop() + try: + data = await loop.run_in_executor(None, self._proc.read, n) + except EOFError: + return b"" + except Exception: # noqa: BLE001 — treat any read failure as EOF + return b"" + if not data: + return b"" + return data.encode("utf-8", "replace") if isinstance(data, str) else data + + def write(self, data: str | bytes) -> None: + if self._proc is None: + return + if isinstance(data, bytes): + data = data.decode("utf-8", "replace") + try: + self._proc.write(data) + except Exception: # noqa: BLE001 + pass + + def resize(self, cols: int, rows: int) -> None: + self.cols, self.rows = max(1, int(cols)), max(1, int(rows)) + if self._proc is None: + return + try: + self._proc.setwinsize(self.rows, self.cols) + except Exception: # noqa: BLE001 + pass + + def poll(self) -> int | None: + if self._proc is None: + return self._exit_code + try: + if self._proc.isalive(): + return None + self._exit_code = self._proc.exitstatus + except Exception: # noqa: BLE001 + pass + return self._exit_code + + async def aclose(self) -> int | None: + if self._proc is not None: + try: + self._proc.terminate(force=True) + except Exception: # noqa: BLE001 + pass + self._proc = None + return self._exit_code + + +def open_session(**kw): + """Construct the right PTY session for the platform: ``WinPtySession`` on Windows + (pywinpty), else the POSIX ``PtySession``.""" + return WinPtySession(**kw) if sys.platform == "win32" else PtySession(**kw) diff --git a/pyproject.toml b/pyproject.toml index 8636e02..d7217c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "terminal-plugin" -version = "0.2.0" # keep in lockstep with protoagent.plugin.yaml (a test enforces it) +version = "0.3.0" # keep in lockstep with protoagent.plugin.yaml (a test enforces it) description = "A full terminal (xterm.js + a real PTY over WebSocket) as a protoAgent console plugin." requires-python = ">=3.11" -# No runtime pip deps: the PTY backend is stdlib (pty/os/fcntl/termios), the host -# (protoAgent) provides fastapi + langchain-core, and xterm.js is VENDORED + served -# locally (offline — no CDN). The only OS requirement is a Unix PTY (Linux/macOS). +# Linux/macOS: no runtime pip deps — the PTY backend is stdlib (pty/os/fcntl/termios), +# the host provides fastapi + langchain-core, and xterm.js is VENDORED + served locally +# (offline). Windows is EXPERIMENTAL and needs pywinpty (manifest requires_pip). [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py index 18e3848..6410572 100644 --- a/tests/test_pty_session.py +++ b/tests/test_pty_session.py @@ -5,7 +5,9 @@ import asyncio -from terminal.pty_session import PtySession, default_shell +import pytest + +from terminal.pty_session import PtyError, PtySession, WinPtySession, default_shell, open_session async def _read_until(sess, marker: str, timeout: float = 8.0) -> bytes: @@ -74,3 +76,22 @@ async def _drain(): assert s.poll() is not None # the child exited → reaped, exit code known finally: await s.aclose() + + +# ── backend selection + the Windows (pywinpty) backend ────────────────────────── + + +def test_open_session_picks_posix_on_this_platform(): + # The suite runs on Linux/macOS → the POSIX backend. + assert isinstance(open_session(shell="/bin/sh"), PtySession) + + +def test_winpty_build_env_and_missing_dep(): + s = WinPtySession(scrub_env=["SECRET_API_KEY"], env_overrides={"FOO": "bar"}) + env = s._build_env() + assert env["TERM"] == "xterm-256color" and env["FOO"] == "bar" + assert "SECRET_API_KEY" not in env + # pywinpty isn't installed off Windows → start() raises a clear PtyError (not a raw + # ImportError), so the bridge surfaces a useful message. + with pytest.raises(PtyError): + s.start()