From e1e69650614c43530af3e85d225130fc7b0b95e7 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 13 Jun 2026 11:17:30 -0700 Subject: [PATCH 1/2] feat: enable by default + correct the no-restart install docs (v0.1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable by default (manifest enabled: true). Safe here: the WebSocket is operator- bearer-gated, and protoAgent only binds non-loopback when a token is set, so an un-gated shell is always loopback-local. Disable via plugins.disabled: [terminal]. - Fix the install docs: enabling a plugin HOT-MOUNTS its router (#822) and the rail picks up the view from runtime-status (#853) — no restart. The old "enable; then restart" line was stale boilerplate. Use the console Plugins panel (or reload_plugins / Sync) to pick up a CLI install live. Co-Authored-By: Claude Fable 5 --- README.md | 28 ++++++++++++++++++---------- __init__.py | 3 ++- protoagent.plugin.yaml | 9 +++++---- pyproject.toml | 2 +- tests/test_packaging.py | 2 +- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index bf53021..76b8617 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ Install into any protoAgent agent from this git URL — it's not tied to one age A terminal is **interactive shell access on the host**. This plugin: -- **Ships DISABLED.** Enabling it is a deliberate trust decision (install ≠ enable). +- **Enabled by default** — once installed it's on. That's safe because the WebSocket + is bearer-gated (below) and protoAgent only binds a non-loopback interface when a + token is set, so an un-gated shell is always loopback-local. Disable it explicitly + (`plugins.disabled: [terminal]`) if you don't want a terminal. - **Gates the WebSocket on the operator bearer.** The page gets the bearer from the DS-kit handshake and opens `…/ws?token=`; the server verifies it against the host's configured token (`auth.token` / `A2A_AUTH_TOKEN`) — the same token the @@ -42,19 +45,24 @@ A terminal is **interactive shell access on the host**. This plugin: - No pip deps (the PTY is stdlib). xterm.js loads from jsDelivr — needs network at view-load time (vendoring is a planned follow-up for airgapped installs). -## Install +## Install — no restart needed + +Easiest: the console **Plugins** panel — paste the git URL, install. It's enabled by +default, its router **hot-mounts** (#822), and the **Terminal** rail icon appears from +runtime-status without a console rebuild (#853). No restart. + +Or from the CLI against a running server: ```bash -python -m server plugin install https://github.com/protoLabsAI/terminal-plugin --ref main -python -m server plugin enable terminal # the trust decision; then restart +python -m server plugin install https://github.com/protoLabsAI/terminal-plugin --ref v0.1.1 +# then pick it up live: hit "Sync" in the console Plugins panel, or have the agent call +# reload_plugins (plugin-devkit). It hot-mounts — no restart. ``` -Then in `config/langgraph-config.yaml`: +Optional config in `config/langgraph-config.yaml` (all have sane defaults): ```yaml -plugins: - enabled: [terminal] - +# enabled by default; to turn it OFF: plugins: { disabled: [terminal] } terminal: shell: "" # blank → $SHELL, then /bin/bash cwd: "" # blank → the server's cwd @@ -62,8 +70,8 @@ terminal: font_size: 13 ``` -Open the **Terminal** rail icon. (Make sure the host has an operator bearer set — -`auth.token` or `A2A_AUTH_TOKEN` — before exposing the port.) +Then open the **Terminal** rail icon. (Make sure the host has an operator bearer set — +`auth.token` or `A2A_AUTH_TOKEN` — before binding a non-loopback interface.) ## Layout diff --git a/__init__.py b/__init__.py index fe9e116..233faf5 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,8 @@ view page (an iframe page-load can't carry a bearer, so the page must be public) and the WebSocket, which verifies the operator bearer itself from a ``?token=`` query param (a browser WS can't set an Authorization header). No tools — it's a view + a -PTY bridge. Ships DISABLED: enabling grants interactive shell access on the host. +PTY bridge. Enabled by default — the WS bearer gate is the protection, and an un-gated +shell is only ever loopback-local (protoAgent requires a token to bind non-loopback). """ from __future__ import annotations diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 606151c..8a61f99 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,14 +1,15 @@ id: terminal name: Terminal -version: 0.1.0 +version: 0.1.1 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 (it reads the console's --pl-* tokens and re-themes live). The WebSocket is gated by the operator bearer (the same token the console uses), so only the authenticated - operator gets a shell. Ships DISABLED — enabling it grants the operator interactive - shell access on the host, so it's a deliberate trust decision. -enabled: false + operator gets a shell. Enabled by default: the bearer gate is the real protection, + and protoAgent only binds non-loopback when a token is set — so an un-gated shell is + always loopback-local. (Disable it explicitly if you don't want a terminal.) +enabled: true repository: https://github.com/protoLabsAI/terminal-plugin min_protoagent_version: "0.27.0" # plugin console views (ADR 0026) + WS-through-proxy (#883) diff --git a/pyproject.toml b/pyproject.toml index 8a16de6..5097e67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "terminal-plugin" -version = "0.1.0" # keep in lockstep with protoagent.plugin.yaml (a test enforces it) +version = "0.1.1" # 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" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 03363f7..1f1f360 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -18,7 +18,7 @@ def _manifest(): def test_manifest_shape(): m = _manifest() assert m["id"] == "terminal" - assert m["enabled"] is False # ships DISABLED — enabling grants shell access + assert m["enabled"] is True # on by default — the WS bearer gate is the protection assert m["config_section"] == "terminal" for key in ("shell", "cwd"): assert key in m["config"] From 4386ee23e52d50ae330195357c3c589ea050d791 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 13 Jun 2026 11:28:54 -0700 Subject: [PATCH 2/2] test: bound + de-flake the WS test (pytest-timeout, cat round-trip, CI timeout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WS round-trip used an unbounded receive_json loop racing the shell's prompt/echo — it passed in v0.1.0's CI but hung this run (flaky). Harden it: - pytest-timeout (timeout=60) → any hanging test fails with a traceback, never hangs. - CI job timeout-minutes: 8 (safety net). - the round-trip now drives /bin/cat (deterministic echo, no shell-init race); real shell behaviour stays covered by test_pty_session against /bin/sh. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 1 + pyproject.toml | 1 + requirements-dev.txt | 1 + tests/test_api.py | 9 ++++++--- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b29e8f..632ce69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ on: jobs: test: runs-on: ${{ vars.NSC_RUNNER || 'namespace-profile-protolabs-linux' }} + timeout-minutes: 8 # safety net — these tests drive real PTYs + a WebSocket steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 5097e67..2040fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ requires-python = ">=3.11" asyncio_mode = "auto" testpaths = ["tests"] pythonpath = ["."] +timeout = 60 # no test may hang (these drive PTYs + a WS) — fail with a traceback [tool.ruff] line-length = 120 diff --git a/requirements-dev.txt b/requirements-dev.txt index 4fe40de..3764c0e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,5 +7,6 @@ pyyaml>=6 httpx>=0.27 pytest>=8 pytest-asyncio>=0.23 +pytest-timeout>=2.3 # bound any hanging test (PTY/WS) → fail with a traceback, never hang CI # wsproto powers fastapi/starlette TestClient.websocket_connect (used by the WS test). wsproto>=1.2 diff --git a/tests/test_api.py b/tests/test_api.py index 7677f7a..cdbc3dc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,7 +12,10 @@ def _app(cfg=None): app = FastAPI() - app.include_router(api.build_router(cfg or {"shell": "/bin/sh", "cwd": "/"}), prefix="/plugins/terminal") + # `cat` echoes its stdin deterministically (no shell prompt/init/echo race), so the + # WS round-trip is stable across platforms — the shell behaviour itself is covered + # by test_pty_session against /bin/sh. + app.include_router(api.build_router(cfg or {"shell": "/bin/cat"}), prefix="/plugins/terminal") return app @@ -69,10 +72,10 @@ def test_ws_accepts_the_matching_token(monkeypatch): def test_ws_round_trip_with_a_real_shell(monkeypatch): monkeypatch.delenv("A2A_AUTH_TOKEN", raising=False) # no token → open - c = TestClient(_app({"shell": "/bin/sh", "cwd": "/"})) + c = TestClient(_app({"shell": "/bin/cat"})) # cat echoes input deterministically with c.websocket_connect("/plugins/terminal/ws") as ws: assert ws.receive_json()["type"] == "connected" - ws.send_json({"type": "input", "data": "echo ws_marker_42\n"}) + ws.send_json({"type": "input", "data": "ws_marker_42\n"}) got = "" for _ in range(300): m = ws.receive_json()