A terminal panel beside an agent chat

A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.

The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.

The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 01:44:07 +02:00
parent 246be1fa8e
commit 5117168454
39 changed files with 2981 additions and 34 deletions
+84 -3
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 837 tests, ~50s
pytest # 891 tests, ~55s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -35,7 +35,11 @@ redesign, not a tweak.
1. **No Node, no npm, no build step.** Browser libraries are downloaded once by
`scripts/fetch_vendor.py`, hash-pinned in `scripts/vendor.lock.json`, and
committed under `web/static/vendor/`.
committed under `web/static/vendor/`. Adding one is a `--update`, same as
bumping one: a name in `PACKAGES` with no lock entry has nothing to verify
against, and the script refuses rather than writing it unpinned. xterm is
the one heavy dependency — 280KB, more than everything else together — and
is loaded only on a chat that can open a terminal.
2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work
offline and must not report page views to a third party.
3. **No hard-coded values outside `tokens.css`.** Every colour, space, radius
@@ -86,6 +90,7 @@ src/lembas/
admin_tools.py custom HTTP tools and MCP servers
admin_agents.py whether agent chats exist, and what they may spend
agents.py SSH connections, kept by the people who own them
terminal.py the terminal panel's WebSocket, and its two locks
audio.py transcribe, speak, voice discovery
library.py knowledge, notes, skills pages; memory CRUD
files.py upload, serve, remove attachments
@@ -101,7 +106,8 @@ src/lembas/
search/ ddgs, SearXNG and Firecrawl behind one shape
library/ documents, notes, memories, skills, FTS
mcp/ remote MCP servers: framing, transport, rows to tools
agent/ agent chats: the mode table, SSH, and the four tools
agent/ agent chats: the mode table, SSH, the four tools,
and terminal.py, the shells held open behind the panel
audio.py OpenAI-shaped /v1/audio/* client
fetch.py URL retrieval, HTML to text, the SSRF guard
sharing.py one visibility rule for every library store
@@ -650,6 +656,81 @@ passing one through a worker turns it into one delivery at the end, or nothing.
accepting `text/event-stream`. It is served from `GET /sw.js` rather than the
static mount because a worker's scope is the path it came from.
**XSS is now a root shell, not a leaked chat.** `api/terminal.py` is the one
WebSocket here, it is same-origin, the cookie rides along automatically, and
what it opens is an interactive shell. Every other route a script could reach
gives up a conversation; this one gives up the machine. Nothing about hard rule
6 changes — it was already absolute — but the *price* of getting it wrong did,
and so did the price of a stray `|safe`. The two locks are: the session cookie
is SameSite Lax, so a foreign page's handshake carries no cookie, and the
endpoint additionally **requires** an Origin header matching Host rather than
checking one when it happens to be present.
**A WebSocket dependency must be typed `HTTPConnection`.** `api/deps.py:
get_current_user` used to take a `Request`; FastAPI injects a `WebSocket` on a
websocket route, so the annotation fails at *connect* time rather than at
import. That is a failure which passes every test that does not open a socket
and breaks in a browser. `HTTPConnection` is the shared base and carries both
the cookies and `.state`.
**Terminal sessions are keyed on the chat, and outlive the socket.** A reload is
indistinguishable from a second tab, so anything finer needs an id in the
browser's storage — and then an abandoned tab leaks a PTY nothing in the UI can
find. One chat, one shell; two tabs share it and the smaller window decides the
size. Closing the panel calls `detach`, never `close`: a build running behind a
shut panel is the case the whole lifetime exists for. What ends one is the idle
timeout (nobody attached *and* nothing typed), deleting the chat, disabling,
moving or deleting the connection, forgetting its host key, or a restart.
**Unlike generations, nothing here ends by itself.** `generation.ensure` can
prune inside itself because a reply finishes and something calls in again. A
shell sits at a prompt forever, so `agent/terminal.py` runs a reaper task
instead. Copying the generation shape would mean nothing was ever swept.
**A slow viewer is dropped, not buffered.** Each viewer has a bounded queue; one
that fills is disconnected and reconnects with the scrollback, which costs it
nothing because the scrollback *is* the state. Blocking the pump instead would
stall every other viewer and buffer without bound — and `yes` is one word to
type. The reflex fix is an unbounded queue; it is the wrong one.
**Terminal traffic is bytes in both directions, and nothing decodes it.** A read
on the far side lands mid-character often enough to matter. xterm's decoder is
stateful across `write()` calls, so passing raw bytes through is correct by
construction, while decoding each frame server-side would corrupt every
boundary. Only `resize`, `ready`, `closed` and `error` are text, and they are
JSON.
**The modes do not govern the keyboard.** `agent/policy.py` exists because a
model reads pages, files and command output it did not write and can be talked
into things. A person typing into the panel holds the credential already and
could open the same shell with an ssh client, so nothing they type is checked
against the mode or the two lists. There is a test named after this, because it
reads like a bug next to `policy.py` and "fixing" it would make the panel
useless in the mode people spend the most time in.
**The nginx vhost must pass upgrades through.** `deploy/nginx-vhost.conf` used
to set `Connection ""`, which is right for SSE and fails every WebSocket
handshake — and a failed handshake tells the browser nothing: no status, no
reason. It now uses `map $http_upgrade`, which yields the empty string when
nothing asked to upgrade, so one `location` serves both. `update.sh` has a drift
check for exactly this.
**`data-toggle` syncs every toggle, not the one that was clicked.** A panel can
be opened by the topbar button and closed by its own Close, and now also closed
by nothing at all: `data-toggle-group="side"` makes the terminal and the
inspector mutually exclusive, because at 1280px both plus the sidebar leave the
conversation about seventy pixels wide. `app.js:setPanel` applies the state and
then brings every `[data-toggle]` pointing at that panel in line, and fires
`lembas:toggle` — which is how `terminal.js` learns it is visible and may
measure itself. xterm's `fit()` reads `offsetWidth`, which is 0 inside a
`[hidden]` ancestor, so fitting early is a silent no-op that leaves an
80-column terminal in a 34rem panel.
**xterm holds colours as values, so the theme has to be pushed at it.**
`applyTheme` dispatches `lembas:theme`; without it, switching to `shire` leaves
a black rectangle in a light interface. Same reason a `ResizeObserver` is on the
panel: a window `resize` never fires when the sidebar is toggled beside it.
## Changing the schema
There is no Alembic, but there *is* `db/migrations.py`. It compares the declared