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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user