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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<bearer>`; the server verifies it against the
host's configured token (`auth.token` / `A2A_AUTH_TOKEN`) — the same token the
Expand All @@ -42,28 +45,33 @@ 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
scrollback: 5000
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

Expand Down
3 changes: 2 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 6 additions & 3 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading