Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]`.
4 changes: 2 additions & 2 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
128 changes: 119 additions & 9 deletions pty_session.py
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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)
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 depsthe 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"
Expand Down
23 changes: 22 additions & 1 deletion tests/test_pty_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Loading